diff options
author | Adam Michael <ajmichael@google.com> | 2017-03-22 15:33:40 +0000 |
---|---|---|
committer | Yue Gan <yueg@google.com> | 2017-03-23 09:47:34 +0000 |
commit | 2587a6dd010266c749b6234115b9333830a951a7 (patch) | |
tree | b8ccfc24b3fc8a8e9ff9040c3f95a8edb952ed3b /src/test/java | |
parent | 832548dd91747cf8cef360c7bcb5a5f1e5ecf28e (diff) |
Open source some Android tools' tests.
--
PiperOrigin-RevId: 150881315
MOS_MIGRATED_REVID=150881315
Diffstat (limited to 'src/test/java')
11 files changed, 5130 insertions, 0 deletions
diff --git a/src/test/java/com/google/devtools/build/android/AaptCommandBuilderTest.java b/src/test/java/com/google/devtools/build/android/AaptCommandBuilderTest.java new file mode 100644 index 0000000000..f6762a3b93 --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/AaptCommandBuilderTest.java @@ -0,0 +1,262 @@ +// 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; + +import static com.google.common.truth.Truth.assertThat; + +import com.android.builder.core.VariantType; +import com.android.repository.Revision; +import com.google.common.collect.ImmutableList; +import com.google.common.testing.NullPointerTester; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.util.ArrayList; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link AaptCommandBuilder}. */ +@RunWith(JUnit4.class) +public class AaptCommandBuilderTest { + private Path aapt; + private Path manifest; + + @Before + public void createPaths() { + FileSystem fs = FileSystems.getDefault(); + aapt = fs.getPath("aapt"); + manifest = fs.getPath("AndroidManifest.xml"); + } + + @Test + public void testPassesNullPointerTester() throws Exception { + NullPointerTester tester = new NullPointerTester().setDefault(Path.class, aapt); + + tester.testConstructor(AaptCommandBuilder.class.getConstructor(Path.class)); + tester.testAllPublicInstanceMethods(new AaptCommandBuilder(aapt)); + tester.testAllPublicInstanceMethods(new AaptCommandBuilder(aapt).when(true)); + tester.testAllPublicInstanceMethods(new AaptCommandBuilder(aapt).when(false)); + } + + @Test + public void testAaptPathAddedAsFirstArgument() { + assertThat(new AaptCommandBuilder(aapt).build()).containsExactly(aapt.toString()); + } + + @Test + public void testAddArgumentAddedToList() { + assertThat(new AaptCommandBuilder(aapt).add("package").build()).contains("package"); + } + + @Test + public void testAddCallsAddedToEndOfList() { + assertThat(new AaptCommandBuilder(aapt).add("package").add("-f").build()) + .containsExactly(aapt.toString(), "package", "-f") + .inOrder(); + } + + @Test + public void testAddWithStringValueAddsFlagThenValueAsSeparateWords() { + assertThat(new AaptCommandBuilder(aapt).add("-0", "gif").build()) + .containsExactly(aapt.toString(), "-0", "gif") + .inOrder(); + } + + @Test + public void testAddWithEmptyValueAddsNothing() { + assertThat(new AaptCommandBuilder(aapt).add("-0", "").build()).doesNotContain("-0"); + } + + @Test + public void testAddWithNullStringValueAddsNothing() { + assertThat(new AaptCommandBuilder(aapt).add("-0", (String) null).build()).doesNotContain("-0"); + } + + @Test + public void testAddWithPathValueAddsFlagThenStringValueAsSeparateWords() { + assertThat(new AaptCommandBuilder(aapt).add("-M", manifest).build()) + .containsExactly(aapt.toString(), "-M", manifest.toString()) + .inOrder(); + } + + @Test + public void testAddWithNullPathValueAddsNothing() { + assertThat(new AaptCommandBuilder(aapt).add("-M", (Path) null).build()).doesNotContain("-M"); + } + + @Test + public void testAddRepeatedWithEmptyValuesAddsNothing() { + assertThat(new AaptCommandBuilder(aapt).addRepeated("-0", ImmutableList.<String>of()).build()) + .doesNotContain("-0"); + } + + @Test + public void testAddRepeatedWithSingleValueAddsOneFlagOneValue() { + assertThat(new AaptCommandBuilder(aapt).addRepeated("-0", ImmutableList.of("gif")).build()) + .containsExactly(aapt.toString(), "-0", "gif") + .inOrder(); + } + + @Test + public void testAddRepeatedWithMultipleValuesAddsFlagBeforeEachValue() { + assertThat( + new AaptCommandBuilder(aapt).addRepeated("-0", ImmutableList.of("gif", "png")).build()) + .containsExactly(aapt.toString(), "-0", "gif", "-0", "png") + .inOrder(); + } + + @Test + public void testAddRepeatedSkipsNullValues() { + ArrayList<String> list = new ArrayList<>(3); + list.add("gif"); + list.add(null); + list.add("png"); + assertThat( + new AaptCommandBuilder(aapt).addRepeated("-0", list).build()) + .containsExactly(aapt.toString(), "-0", "gif", "-0", "png") + .inOrder(); + } + + + @Test + public void testThenAddFlagForwardsCallAfterWhenTrue() { + assertThat( + new AaptCommandBuilder(aapt).when(true).thenAdd("--addthisflag").build()) + .contains("--addthisflag"); + } + + @Test + public void testThenAddFlagWithValueForwardsCallAfterWhenTrue() { + assertThat( + new AaptCommandBuilder(aapt) + .when(true).thenAdd("--addthisflag", "andthisvalue").build()) + .contains("--addthisflag"); + } + + @Test + public void testThenAddFlagWithPathForwardsCallAfterWhenTrue() { + assertThat( + new AaptCommandBuilder(aapt) + .when(true).thenAdd("--addthisflag", manifest).build()) + .contains("--addthisflag"); + } + + @Test + public void testThenAddRepeatedForwardsCallAfterWhenTrue() { + assertThat( + new AaptCommandBuilder(aapt) + .when(true).thenAddRepeated("--addthisflag", ImmutableList.of("andthesevalues")) + .build()) + .contains("--addthisflag"); + } + + @Test + public void testThenAddFlagDoesNothingAfterWhenFalse() { + assertThat( + new AaptCommandBuilder(aapt).when(false).thenAdd("--dontaddthisflag").build()) + .doesNotContain("--dontaddthisflag"); + } + + @Test + public void testThenAddFlagWithValueDoesNothingAfterWhenFalse() { + assertThat( + new AaptCommandBuilder(aapt) + .when(false).thenAdd("--dontaddthisflag", "orthisvalue").build()) + .doesNotContain("--dontaddthisflag"); + } + + @Test + public void testThenAddFlagWithPathDoesNothingAfterWhenFalse() { + assertThat( + new AaptCommandBuilder(aapt) + .when(false).thenAdd("--dontaddthisflag", manifest).build()) + .doesNotContain("--dontaddthisflag"); + } + + @Test + public void testThenAddRepeatedDoesNothingAfterWhenFalse() { + assertThat( + new AaptCommandBuilder(aapt) + .when(false).thenAddRepeated("--dontaddthisflag", ImmutableList.of("orthesevalues")) + .build()) + .doesNotContain("--dontaddthisflag"); + } + + @Test + public void testWhenVersionIsAtLeastAddsFlagsForEqualVersion() { + assertThat( + new AaptCommandBuilder(aapt).forBuildToolsVersion(new Revision(23)) + .whenVersionIsAtLeast(new Revision(23)).thenAdd("--addthisflag") + .build()) + .contains("--addthisflag"); + } + + @Test + public void testWhenVersionIsAtLeastAddsFlagsForGreaterVersion() { + assertThat( + new AaptCommandBuilder(aapt).forBuildToolsVersion(new Revision(24)) + .whenVersionIsAtLeast(new Revision(23)).thenAdd("--addthisflag") + .build()) + + .contains("--addthisflag"); + } + + @Test + public void testWhenVersionIsAtLeastAddsFlagsForUnspecifiedVersion() { + assertThat( + new AaptCommandBuilder(aapt) + .whenVersionIsAtLeast(new Revision(23)).thenAdd("--addthisflag") + .build()) + .contains("--addthisflag"); + } + + @Test + public void testWhenVersionIsAtLeastDoesNotAddFlagsForLesserVersion() { + assertThat( + new AaptCommandBuilder(aapt).forBuildToolsVersion(new Revision(22)) + .whenVersionIsAtLeast(new Revision(23)).thenAdd("--dontaddthisflag") + .build()) + .doesNotContain("--dontaddthisflag"); + } + + @Test + public void testWhenVariantIsAddsFlagsForEqualVariantType() { + assertThat( + new AaptCommandBuilder(aapt).forVariantType(VariantType.LIBRARY) + .whenVariantIs(VariantType.LIBRARY).thenAdd("--addthisflag") + .build()) + .contains("--addthisflag"); + } + + @Test + public void testWhenVariantIsDoesNotAddFlagsForUnequalVariantType() { + assertThat( + new AaptCommandBuilder(aapt).forVariantType(VariantType.DEFAULT) + .whenVariantIs(VariantType.LIBRARY).thenAdd("--dontaddthisflag") + .build()) + .doesNotContain("--dontaddthisflag"); + } + + @Test + public void testWhenVariantIsDoesNotAddFlagsForUnspecifiedVariantType() { + assertThat( + new AaptCommandBuilder(aapt) + .whenVariantIs(VariantType.LIBRARY).thenAdd("--dontaddthisflag") + .build()) + .doesNotContain("--dontaddthisflag"); + } + +} diff --git a/src/test/java/com/google/devtools/build/android/AndroidDataBuilder.java b/src/test/java/com/google/devtools/build/android/AndroidDataBuilder.java new file mode 100644 index 0000000000..3e92eb64a4 --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/AndroidDataBuilder.java @@ -0,0 +1,202 @@ +// 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; + +import com.android.ide.common.res2.MergingException; +import com.google.common.base.Joiner; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +/** + * Utility for building {@link UnvalidatedAndroidData}, {@link ParsedAndroidData}, + * {@link DependencyAndroidData} and {@link MergedAndroidData}. + */ +public class AndroidDataBuilder { + /** Templates for resource files generation. */ + public enum ResourceType { + VALUE { + @Override + public String create(String... lines) { + return String.format( + "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>%s</resources>", + Joiner.on("\n").join(lines)); + } + }, + LAYOUT { + @Override + public String create(String... lines) { + return String.format( + "<?xml version=\"1.0\" encoding=\"utf-8\"?>" + + "<LinearLayout xmlns:android=\"http://schemas.android.com/apk/res/android\"" + + " android:layout_width=\"fill_parent\"" + + " android:layout_height=\"fill_parent\">%s</LinearLayout>", + 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); + } + + public static AndroidDataBuilder of(Path root) { + return new AndroidDataBuilder(root); + } + + private final Path root; + private final Path assetDir; + private final Path resourceDir; + private Map<Path, String> filesToWrite = new HashMap<>(); + private Map<Path, Path> filesToCopy = new HashMap<>(); + private Path manifest; + private Path rTxt; + + private AndroidDataBuilder(Path root) { + this.root = root; + assetDir = root.resolve("assets"); + resourceDir = root.resolve("res"); + } + + AndroidDataBuilder copyOf(Path newRoot) { + AndroidDataBuilder result = new AndroidDataBuilder(newRoot); + result.filesToWrite = rerootPaths(this.filesToWrite, this.root, newRoot); + result.filesToCopy = rerootPaths(this.filesToCopy, this.root, newRoot); + result.manifest = this.manifest; + result.rTxt = this.rTxt; + return result; + } + + public AndroidDataBuilder addResource( + String path, AndroidDataBuilder.ResourceType template, String... lines) { + filesToWrite.put(resourceDir.resolve(path), template.create(lines)); + return this; + } + + public AndroidDataBuilder addValuesWithAttributes( + String path, Map<String, String> attributes, String... lines) { + ImmutableList.Builder<String> attributeBuilder = ImmutableList.builder(); + for (Entry<String, String> attribute : attributes.entrySet()) { + if (attribute.getKey() != null && attribute.getValue() != null) { + attributeBuilder.add(String.format("%s=\"%s\"", attribute.getKey(), attribute.getValue())); + } + } + String fileContents = ResourceType.VALUE.create(lines); + fileContents = fileContents.replace("<resources>", + String.format("<resources %s>", Joiner.on(" ").join(attributeBuilder.build()))); + filesToWrite.put(resourceDir.resolve(path), fileContents); + return this; + } + + public AndroidDataBuilder addResourceBinary(String path, Path source) { + final Path target = resourceDir.resolve(path); + filesToCopy.put(target, source); + return this; + } + + public AndroidDataBuilder addAsset(String path, String... lines) { + filesToWrite.put(assetDir.resolve(path), Joiner.on("\n").join(lines)); + return this; + } + + public AndroidDataBuilder createManifest(String path, String manifestPackage, String... lines) { + return createManifest(path, manifestPackage, ImmutableList.<String>of(), lines); + } + + public AndroidDataBuilder createManifest( + String path, String manifestPackage, List<String> namespaces, String... lines) { + this.manifest = root.resolve(path); + filesToWrite.put( + manifest, + String.format( + "<?xml version=\"1.0\" encoding=\"utf-8\"?>" + + "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\" %s" + + " package=\"%s\">" + + "%s</manifest>", + Joiner.on(" ").join(namespaces), + manifestPackage, + Joiner.on("\n").join(lines))); + return this; + } + + public AndroidDataBuilder createRTxt(String path, String... lines) { + this.rTxt = root.resolve(path); + filesToWrite.put(rTxt, Joiner.on("\n").join(lines)); + return this; + } + + public UnvalidatedAndroidData buildUnvalidated() throws IOException { + writeFiles(); + return new UnvalidatedAndroidData( + ImmutableList.of(resourceDir), ImmutableList.of(assetDir), manifest); + } + + public ParsedAndroidData buildParsed() throws IOException, MergingException { + return ParsedAndroidData.from(buildUnvalidated()); + } + + public DependencyAndroidData buildDependency() throws IOException { + writeFiles(); + return new DependencyAndroidData( + ImmutableList.of(resourceDir), + ImmutableList.of(assetDir), + manifest, + rTxt, + null); + } + + public MergedAndroidData buildMerged() throws IOException { + writeFiles(); + return new MergedAndroidData(resourceDir, assetDir, manifest); + } + + private void writeFiles() throws IOException { + Files.createDirectories(assetDir); + Files.createDirectories(resourceDir); + Preconditions.checkNotNull(manifest, "A manifest is required."); + for (Entry<Path, String> entry : filesToWrite.entrySet()) { + Files.createDirectories(entry.getKey().getParent()); + Files.write(entry.getKey(), entry.getValue().getBytes(StandardCharsets.UTF_8)); + Preconditions.checkArgument(Files.exists(entry.getKey())); + } + for (Entry<Path, Path> entry : filesToCopy.entrySet()) { + Path target = entry.getKey(); + Path source = entry.getValue(); + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private <T> Map<Path, T> rerootPaths(Map<Path, T> origMap, Path root, Path newRoot) { + Map<Path, T> newMap = new HashMap<>(); + for (Map.Entry<Path, T> origEntry : origMap.entrySet()) { + Path relPath = root.relativize(origEntry.getKey()); + newMap.put(newRoot.resolve(relPath), origEntry.getValue()); + } + return newMap; + } + +} diff --git a/src/test/java/com/google/devtools/build/android/AndroidDataMergerTest.java b/src/test/java/com/google/devtools/build/android/AndroidDataMergerTest.java new file mode 100644 index 0000000000..3e5ff0cee4 --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/AndroidDataMergerTest.java @@ -0,0 +1,1300 @@ +// 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; + +import static com.google.common.truth.Truth.assertAbout; +import static com.google.common.truth.Truth.assertThat; +import static com.google.devtools.build.android.ParsedAndroidDataBuilder.file; +import static com.google.devtools.build.android.ParsedAndroidDataBuilder.xml; + +import com.google.common.base.Objects; +import com.google.common.base.Optional; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.jimfs.Jimfs; +import com.google.common.truth.FailureStrategy; +import com.google.common.truth.Subject; +import com.google.common.truth.SubjectFactory; +import com.google.devtools.build.android.AndroidDataBuilder.ResourceType; +import com.google.devtools.build.android.AndroidDataMerger.SourceChecker; +import com.google.devtools.build.android.xml.IdXmlResourceValue; +import com.google.devtools.build.android.xml.PublicXmlResourceValue; +import com.google.devtools.build.android.xml.SimpleXmlResourceValue; +import com.google.devtools.build.android.xml.SimpleXmlResourceValue.Type; +import com.google.devtools.build.android.xml.StyleableXmlResourceValue; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link AndroidDataMerger}. */ +@RunWith(JUnit4.class) +public class AndroidDataMergerTest { + + static final String XLIFF_NAMESPACE = "urn:oasis:names:tc:xliff:document:1.2"; + static final String XLIFF_PREFIX = "xliff"; + + private FileSystem fileSystem; + private FullyQualifiedName.Factory fqnFactory; + private TestLoggingHandler loggingHandler; + private Logger mergerLogger; + + @Before + public void setUp() throws Exception { + fileSystem = Jimfs.newFileSystem(); + fqnFactory = FullyQualifiedName.Factory.from(ImmutableList.<String>of()); + mergerLogger = Logger.getLogger(AndroidDataMerger.class.getCanonicalName()); + loggingHandler = new TestLoggingHandler(); + mergerLogger.addHandler(loggingHandler); + } + + @After + public void removeLoggingHandler() { + mergerLogger.removeHandler(loggingHandler); + } + + @Test + public void mergeDirectDeps() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + + DataSource primaryStrings = DataSource.of(primaryRoot.resolve("res/values/resources.xml")); + DataSource directStrings = DataSource.of(directRoot.resolve("res/values/strings.xml")); + + ParsedAndroidData transitiveDependency = ParsedAndroidDataBuilder.empty(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .overwritable( + file("layout/exit").source("res/layout/exit.xml"), + xml("string/exit") + .source(directStrings) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .combining(xml("id/exit").source("values/ids.xml").value(IdXmlResourceValue.of())) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/resources.xml", ResourceType.VALUE, "<string name='exit'>way out</string>") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, false); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable( + xml("string/exit") + .source(primaryStrings.overwrite(directStrings)) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "way out"))) + .build(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable(file("layout/exit").root(directRoot).source("res/layout/exit.xml")) + .combining( + xml("id/exit") + .root(directRoot) + .source("values/ids.xml") + .value(IdXmlResourceValue.of())) + .build()); + + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeDirectAndTransitiveDeps() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + DataSource directString = DataSource.of(directRoot.resolve("res/values/resources.xml")); + DataSource primaryString = + DataSource.of(primaryRoot.resolve("res").resolve("values/resources.xml")); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .overwritable(file("layout/enter").source("res/layout/enter.xml")) + .combining(xml("id/exit").source("values/ids.xml").value(IdXmlResourceValue.of())) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .overwritable( + file("layout/exit").source("res/layout/exit.xml"), + xml("string/exit") + .source(directString) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .combining(xml("id/exit").source("values/ids.xml").value(IdXmlResourceValue.of())) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/resources.xml", ResourceType.VALUE, "<string name='exit'>way out</string>") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, false); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable( + xml("string/exit") + .root(primaryRoot) + .source(primaryString.overwrite(directString)) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "way out"))) + .build(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable( + file("layout/enter").root(transitiveRoot).source("res/layout/enter.xml"), + file("layout/exit").root(directRoot).source("res/layout/exit.xml")) + .combining( + xml("id/exit") + .root(directRoot) + .source("values/ids.xml") + .value(IdXmlResourceValue.of())) + .build()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeWithOverwriteInTransitiveDeps() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + Path descendentRoot = fileSystem.getPath("descendent"); + + DataSource descendentLayout = DataSource.of(descendentRoot.resolve("res/layout/enter.xml")); + DataSource transitiveLayout = DataSource.of(transitiveRoot.resolve("res/layout/enter.xml")); + + DataSource primaryString = DataSource.of(primaryRoot.resolve("res/values/resources.xml")); + DataSource directStrings = DataSource.of(directRoot.resolve("res/values/strings.xml")); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .overwritable(file("layout/enter").source(descendentLayout)) + .overwritable(file("layout/enter").source(transitiveLayout.overwrite(descendentLayout))) + .combining(xml("id/exit").source("values/ids.xml").value(IdXmlResourceValue.of())) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .overwritable( + file("layout/exit").source("res/layout/exit.xml"), + xml("string/exit") + .source(directStrings) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .combining(xml("id/exit").source("values/ids.xml").value(IdXmlResourceValue.of())) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/resources.xml", ResourceType.VALUE, "<string name='exit'>way out</string>") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + UnwrittenMergedAndroidData transitive = + merger.merge(transitiveDependency, directDependency, primary, false); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable( + xml("string/exit") + .source(primaryString.overwrite(directStrings)) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "way out"))) + .build(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable( + file("layout/enter").source(transitiveLayout.overwrite(descendentLayout)), + file("layout/exit").root(directRoot).source("res/layout/exit.xml")) + .combining( + xml("id/exit") + .root(directRoot) + .source("values/ids.xml") + .value(IdXmlResourceValue.of())) + .build()); + assertAbout(unwrittenMergedAndroidData).that(transitive).isEqualTo(expected); + } + + @Test + public void mergeDirectConflict() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + + ParsedAndroidData transitiveDependency = ParsedAndroidDataBuilder.empty(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + // Two string/exit will create conflict. + .overwritable( + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out")), + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + merger.merge(transitiveDependency, directDependency, primary, false); + + assertThat(loggingHandler.warnings) + .containsExactly( + MergeConflict.of( + fqnFactory.parse("string/exit"), + DataResourceXml.createWithNoNamespace( + directRoot.resolve("res/values/strings.xml"), + SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out")), + DataResourceXml.createWithNoNamespace( + directRoot.resolve("res/values/strings.xml"), + SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out"))) + .toConflictMessage()); + } + + @Test + public void mergeDirectConflictDuplicated() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + + ParsedAndroidData transitiveDependency = ParsedAndroidDataBuilder.empty(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + // Two string/exit will create conflict. + .overwritable( + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "way out")), + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "way out"))) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .buildUnvalidated(); + + AndroidDataMerger merger = + AndroidDataMerger.createWithDefaultThreadPool( + new SourceChecker() { + @Override + public boolean checkEquality(DataSource one, DataSource two) throws IOException { + return one.equals(two); + } + }); + + assertAbout(unwrittenMergedAndroidData) + .that(merger.merge(transitiveDependency, directDependency, primary, false)) + .isEqualTo( + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.empty(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable( + xml("string/exit") + .root(directRoot) + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "way out"))) + .build())); + } + + @Test + public void mergeDirectConflictWithPrimaryOverride() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + DataSource primaryStrings = DataSource.of(primaryRoot.resolve("res/values/strings.xml")); + DataSource directStrings = DataSource.of(directRoot.resolve("res/values/strings.xml")); + + ParsedAndroidData transitiveDependency = ParsedAndroidDataBuilder.empty(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + // Two string/exit will create conflict. + .overwritable( + xml("string/exit") + .source(directStrings) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out")), + xml("string/exit") + .source(directStrings) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/strings.xml", ResourceType.VALUE, "<string name='exit'>way out</string>") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, true); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable( + xml("string/exit") + .source(primaryStrings.overwrite(directStrings)) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "way out"))) + .build(), + ParsedAndroidDataBuilder.empty()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeTransitiveConflict() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + // Two string/exit will create conflict. + .overwritable( + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out")), + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .build(); + + ParsedAndroidData directDependency = ParsedAndroidDataBuilder.empty(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + merger.merge(transitiveDependency, directDependency, primary, false); + + assertThat(loggingHandler.warnings) + .containsExactly( + MergeConflict.of( + fqnFactory.parse("string/exit"), + DataResourceXml.createWithNoNamespace( + transitiveRoot.resolve("res/values/strings.xml"), + SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out")), + DataResourceXml.createWithNoNamespace( + transitiveRoot.resolve("res/values/strings.xml"), + SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out"))) + .toConflictMessage()); + } + + @Test + public void mergeTransitiveConflictWithPrimaryOverride() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + // Two string/exit will create conflict. + .overwritable( + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out")), + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory).build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/strings.xml", ResourceType.VALUE, "<string name='exit'>way out</string>") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, true); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable( + xml("string/exit") + .root(primaryRoot) + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "way out"))) + .build(), + ParsedAndroidDataBuilder.empty()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeDirectAndTransitiveConflict() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .overwritable( + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .overwritable( + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out"))) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + merger.merge(transitiveDependency, directDependency, primary, false); + assertThat(loggingHandler.warnings) + .containsExactly( + MergeConflict.of( + fqnFactory.parse("string/exit"), + DataResourceXml.createWithNoNamespace( + directRoot.resolve("res/values/strings.xml"), + SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out")), + DataResourceXml.createWithNoNamespace( + transitiveRoot.resolve("res/values/strings.xml"), + SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out"))) + .toConflictMessage()); + } + + @Test + public void mergeDirectTransitivePrimaryConflictWithoutPrimaryOverride() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .overwritable( + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .overwritable( + xml("string/exit") + .source("values/strings.xml") + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out"))) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/strings.xml", ResourceType.VALUE, "<string name='exit'>way out</string>") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + merger.merge(transitiveDependency, directDependency, primary, false); + + FullyQualifiedName fullyQualifiedName = fqnFactory.parse("string/exit"); + assertThat(loggingHandler.warnings) + .containsExactly( + MergeConflict.of( + fullyQualifiedName, + DataResourceXml.createWithNoNamespace( + directRoot.resolve("res/values/strings.xml"), + SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out")), + DataResourceXml.createWithNoNamespace( + transitiveRoot.resolve("res/values/strings.xml"), + SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out"))) + .toConflictMessage()); + } + + @Test + public void mergeDirectTransitivePrimaryConflictWithPrimaryOverride() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + DataSource primaryStrings = DataSource.of(primaryRoot.resolve("res/values/strings.xml")); + DataSource directStrings = DataSource.of(directRoot.resolve("res/values/strings.xml")); + DataSource transitiveStrings = DataSource.of(transitiveRoot.resolve("res/values/strings.xml")); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .overwritable( + xml("string/exit") + .source(transitiveStrings) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "no way out"))) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .overwritable( + xml("string/exit") + .source(directStrings) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "wrong way out"))) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/strings.xml", ResourceType.VALUE, "<string name='exit'>way out</string>") + .buildUnvalidated(); + + UnwrittenMergedAndroidData data = + AndroidDataMerger.createWithDefaults() + .merge(transitiveDependency, directDependency, primary, true); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(fqnFactory) + .overwritable( + xml("string/exit") + .source(primaryStrings.overwrite(directStrings)) + .value(SimpleXmlResourceValue.createWithValue(Type.STRING, "way out"))) + .build(), + ParsedAndroidDataBuilder.empty()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeDirectAndTransitiveNinepatchConflict() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + // A drawable nine-patch png and plain png with the same base filename will conflict. + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .overwritable(file("drawable/rounded_corners").source("drawable/rounded_corners.9.png")) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .overwritable(file("drawable/rounded_corners").source("drawable/rounded_corners.png")) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + merger.merge(transitiveDependency, directDependency, primary, false); + + assertThat(loggingHandler.warnings) + .containsExactly( + MergeConflict.of( + fqnFactory.parse("drawable/rounded_corners"), + DataValueFile.of(directRoot.resolve("res/drawable/rounded_corners.png")), + DataValueFile.of(transitiveRoot.resolve("res/drawable/rounded_corners.9.png"))) + .toConflictMessage()); + } + + @Test + public void mergeAssetsDirectDeps() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + + ParsedAndroidData transitiveDependency = ParsedAndroidDataBuilder.empty(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addAsset("bin/boojum") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, false); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.builder() + .assets(file().root(primaryRoot).source("bin/boojum")) + .build(), + ParsedAndroidDataBuilder.builder() + .assets(file().root(directRoot).source("hunting/of/the/snark.txt")) + .build()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeCombiningResources() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .combining( + xml("styleable/RubADubDub") + .source("values/attrs.xml") + .value( + StyleableXmlResourceValue.createAllAttrAsReferences( + fqnFactory.parse("attr/baker")))) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .combining( + xml("styleable/RubADubDub") + .source("values/attrs.xml") + .value( + StyleableXmlResourceValue.createAllAttrAsReferences( + fqnFactory.parse("attr/candlestickmaker")))) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/attrs.xml", + ResourceType.VALUE, + "<declare-styleable name='RubADubDub'>", + " <attr name='butcher'/>", + "</declare-styleable>") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, false); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(primaryRoot, fqnFactory) + .combining( + xml("styleable/RubADubDub") + .source("values/attrs.xml") + .value( + StyleableXmlResourceValue.createAllAttrAsReferences( + fqnFactory.parse("attr/baker"), + fqnFactory.parse("attr/butcher"), + fqnFactory.parse("attr/candlestickmaker")))) + .build(), + ParsedAndroidDataBuilder.empty()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeCombiningResourcesWithNamespaces() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + // TODO(corysmith): Make conflict uris for a single prefix and error + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .combining( + xml("styleable/RubADubDub") + .source("values/attrs.xml") + .namespace(XLIFF_PREFIX, XLIFF_NAMESPACE) + .namespace("tools", "http://schemas.android.com/tools") + .value( + StyleableXmlResourceValue.createAllAttrAsReferences( + fqnFactory.parse("attr/baker")))) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .combining( + xml("styleable/RubADubDub") + .source("values/attrs.xml") + .namespace(XLIFF_PREFIX, "wrong uri") + .value( + StyleableXmlResourceValue.createAllAttrAsReferences( + fqnFactory.parse("attr/candlestickmaker")))) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/attrs.xml", + ResourceType.VALUE, + "<declare-styleable name='RubADubDub'>", + " <attr name='butcher'/>", + "</declare-styleable>") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, false); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(primaryRoot, fqnFactory) + .combining( + xml("styleable/RubADubDub") + .source("values/attrs.xml") + .namespace(XLIFF_PREFIX, "wrong uri") + .namespace("tools", "http://schemas.android.com/tools") + .value( + StyleableXmlResourceValue.createAllAttrAsReferences( + fqnFactory.parse("attr/baker"), + fqnFactory.parse("attr/butcher"), + fqnFactory.parse("attr/candlestickmaker")))) + .build(), + ParsedAndroidDataBuilder.empty()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeCombiningLayoutIDResources() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + DataSource transitiveLayout = + DataSource.of(transitiveRoot.resolve("res/layout/transitive.xml")); + DataSource directLayout = DataSource.of(directRoot.resolve("res/layout/direct.xml")); + DataSource primaryLayout = DataSource.of(primaryRoot.resolve("res/layout/primary.xml")); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .overwritable(file("layout/transitive").source(transitiveLayout)) + .combining( + xml("id/back_door").source(transitiveLayout).value(IdXmlResourceValue.of()), + xml("id/door").source("values/ids.xml").value(IdXmlResourceValue.of()), + xml("id/slide").source(transitiveLayout).value(IdXmlResourceValue.of())) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .overwritable(file("layout/zzDirect").source(directLayout)) + .combining( + xml("id/door").source(directLayout).value(IdXmlResourceValue.of()), + xml("id/slide").source(directLayout).value(IdXmlResourceValue.of()), + xml("id/window").source(directLayout).value(IdXmlResourceValue.of())) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "layout/primary.xml", + ResourceType.LAYOUT, + "<TextView android:id=\"@+id/door\"", + " android:text=\"this way ---> \"", + " android:layout_width=\"wrap_content\"", + " android:layout_height=\"wrap_content\" />", + "<TextView android:id=\"@+id/window\"", + " android:text=\"no, not that way\"", + " android:layout_width=\"wrap_content\"", + " android:layout_height=\"wrap_content\" />") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, false); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(primaryRoot, fqnFactory) + .overwritable(file("layout/primary").source(primaryLayout)) + .combining( + xml("id/window").source("layout/primary.xml").value(IdXmlResourceValue.of()), + xml("id/door") + .root(transitiveRoot) + .source("values/ids.xml") + .value(IdXmlResourceValue.of())) + .build(), + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .overwritable( + file("layout/transitive").source(transitiveLayout), + file("layout/zzDirect").source(directLayout)) + .combining( + xml("id/back_door").source(transitiveLayout).value(IdXmlResourceValue.of()), + xml("id/slide").source(directLayout).value(IdXmlResourceValue.of())) + .build()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeCombiningPublicResources() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot, fqnFactory) + .combining( + xml("public/RubADubDub") + .source("values/public.xml") + .value( + PublicXmlResourceValue.create( + com.android.resources.ResourceType.STRING, Optional.of(0x7f040000)))) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot, fqnFactory) + .combining( + xml("public/RubADubDub") + .source("values/public.xml") + .value( + PublicXmlResourceValue.create( + com.android.resources.ResourceType.DIMEN, Optional.of(0x7f020000)))) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addResource( + "values/public.xml", + ResourceType.VALUE, + "<public name='RubADubDub' type='color' id='0x7f030000' />") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, false); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(primaryRoot, fqnFactory) + .combining( + xml("public/RubADubDub") + .source("values/public.xml") + .value( + PublicXmlResourceValue.of( + ImmutableMap.of( + com.android.resources.ResourceType.COLOR, + Optional.of(0x7f030000), + com.android.resources.ResourceType.DIMEN, + Optional.of(0x7f020000), + com.android.resources.ResourceType.STRING, + Optional.of(0x7f040000))))) + .build(), + ParsedAndroidDataBuilder.empty()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeAssetsDirectAndTransitiveDeps() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot) + .assets(file().source("hunting/of/the/jubjub.bird")) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addAsset("bin/boojum") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, false); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.builder() + .assets(file().root(primaryRoot).source("bin/boojum")) + .build(), + ParsedAndroidDataBuilder.builder() + .assets( + file().root(directRoot).source("hunting/of/the/snark.txt"), + file().root(transitiveRoot).source("hunting/of/the/jubjub.bird")) + .build()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeAssetsDirectConflict() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRootOne = fileSystem.getPath("directOne"); + Path directRootTwo = fileSystem.getPath("directTwo"); + + ParsedAndroidData transitiveDependency = ParsedAndroidDataBuilder.empty(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.builder() + .assets( + file().root(directRootOne).source("hunting/of/the/snark.txt"), + file().root(directRootTwo).source("hunting/of/the/snark.txt")) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + merger.merge(transitiveDependency, directDependency, primary, false); + assertThat(loggingHandler.warnings) + .containsExactly( + MergeConflict.of( + RelativeAssetPath.Factory.of(directRootOne.resolve("assets")) + .create(directRootOne.resolve("assets/hunting/of/the/snark.txt")), + DataValueFile.of(directRootOne.resolve("assets/hunting/of/the/snark.txt")), + DataValueFile.of(directRootTwo.resolve("assets/hunting/of/the/snark.txt"))) + .toConflictMessage()); + } + + @Test + public void mergeAssetsDirectConflictWithPrimaryOverride() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRootOne = fileSystem.getPath("directOne"); + Path directRootTwo = fileSystem.getPath("directTwo"); + + ParsedAndroidData transitiveDependency = ParsedAndroidDataBuilder.empty(); + String assetFile = "hunting/of/the/snark.txt"; + + DataSource primarySource = DataSource.of(primaryRoot.resolve("assets/" + assetFile)); + DataSource directSource = DataSource.of(directRootTwo.resolve("assets/" + assetFile)); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.builder() + .assets( + file().root(directRootOne).source(assetFile), + file().root(directRootTwo).source(assetFile)) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addAsset(assetFile) + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, true); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.builder() + .assets(file().root(primaryRoot).source(primarySource.overwrite(directSource))) + .build(), + ParsedAndroidDataBuilder.empty()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeAssetsTransitiveConflict() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path transitiveRootOne = fileSystem.getPath("transitiveOne"); + Path transitiveRootTwo = fileSystem.getPath("transitiveTwo"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.builder() + .assets( + file().root(transitiveRootOne).source("hunting/of/the/snark.txt"), + file().root(transitiveRootTwo).source("hunting/of/the/snark.txt")) + .build(); + + ParsedAndroidData directDependency = ParsedAndroidDataBuilder.empty(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + merger.merge(transitiveDependency, directDependency, primary, false); + assertThat(loggingHandler.warnings) + .containsExactly( + MergeConflict.of( + RelativeAssetPath.Factory.of(transitiveRootOne.resolve("assets")) + .create(transitiveRootOne.resolve("assets/hunting/of/the/snark.txt")), + DataValueFile.of(transitiveRootOne.resolve("assets/hunting/of/the/snark.txt")), + DataValueFile.of(transitiveRootTwo.resolve("assets/hunting/of/the/snark.txt"))) + .toConflictMessage()); + } + + @Test + public void mergeAssetsTransitiveConflictWithPrimaryOverride() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(); + + ParsedAndroidData directDependency = ParsedAndroidDataBuilder.empty(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addAsset("hunting/of/the/snark.txt") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + UnwrittenMergedAndroidData data = + merger.merge(transitiveDependency, directDependency, primary, true); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(primaryRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(), + ParsedAndroidDataBuilder.empty()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + @Test + public void mergeAssetsDirectAndTransitiveConflict() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + merger.merge(transitiveDependency, directDependency, primary, false); + assertThat(loggingHandler.warnings) + .containsExactly( + MergeConflict.of( + RelativeAssetPath.Factory.of(directRoot.resolve("assets")) + .create(directRoot.resolve("assets/hunting/of/the/snark.txt")), + DataValueFile.of(directRoot.resolve("assets/hunting/of/the/snark.txt")), + DataValueFile.of(transitiveRoot.resolve("assets/hunting/of/the/snark.txt"))) + .toConflictMessage()); + } + + @Test + public void mergeAssetsDirectTransitivePrimaryConflictWithoutPrimaryOverride() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addAsset("hunting/of/the/snark.txt") + .buildUnvalidated(); + + AndroidDataMerger merger = AndroidDataMerger.createWithDefaults(); + + merger.merge(transitiveDependency, directDependency, primary, false); + assertThat(loggingHandler.warnings) + .containsExactly( + MergeConflict.of( + RelativeAssetPath.Factory.of(directRoot.resolve("assets")) + .create(directRoot.resolve("assets/hunting/of/the/snark.txt")), + DataValueFile.of(directRoot.resolve("assets/hunting/of/the/snark.txt")), + DataValueFile.of(transitiveRoot.resolve("assets/hunting/of/the/snark.txt"))) + .toConflictMessage()); + } + + @Test + public void mergeAssetsDirectTransitivePrimaryConflictWithPrimaryOverride() throws Exception { + Path primaryRoot = fileSystem.getPath("primary"); + Path directRoot = fileSystem.getPath("direct"); + Path transitiveRoot = fileSystem.getPath("transitive"); + + DataSource primarySource = + DataSource.of(primaryRoot.resolve("assets/hunting/of/the/snark.txt")); + DataSource directSource = DataSource.of(directRoot.resolve("assets/hunting/of/the/snark.txt")); + + ParsedAndroidData transitiveDependency = + ParsedAndroidDataBuilder.buildOn(transitiveRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(); + + ParsedAndroidData directDependency = + ParsedAndroidDataBuilder.buildOn(directRoot) + .assets(file().source("hunting/of/the/snark.txt")) + .build(); + + UnvalidatedAndroidData primary = + AndroidDataBuilder.of(primaryRoot) + .createManifest("AndroidManifest.xml", "com.google.mergetest") + .addAsset("hunting/of/the/snark.txt") + .buildUnvalidated(); + + UnwrittenMergedAndroidData data = + AndroidDataMerger.createWithDefaults() + .merge(transitiveDependency, directDependency, primary, true); + + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + primary.getManifest(), + ParsedAndroidDataBuilder.buildOn(primaryRoot) + .assets(file().source(primarySource.overwrite(directSource))) + .build(), + ParsedAndroidDataBuilder.empty()); + assertAbout(unwrittenMergedAndroidData).that(data).isEqualTo(expected); + } + + final SubjectFactory<UnwrittenMergedAndroidDataSubject, UnwrittenMergedAndroidData> + unwrittenMergedAndroidData = + new SubjectFactory<UnwrittenMergedAndroidDataSubject, UnwrittenMergedAndroidData>() { + @Override + public UnwrittenMergedAndroidDataSubject getSubject( + FailureStrategy fs, UnwrittenMergedAndroidData that) { + return new UnwrittenMergedAndroidDataSubject(fs, that); + } + }; + + static class UnwrittenMergedAndroidDataSubject + extends Subject<UnwrittenMergedAndroidDataSubject, UnwrittenMergedAndroidData> { + + static final SubjectFactory<UnwrittenMergedAndroidDataSubject, UnwrittenMergedAndroidData> + FACTORY = + new SubjectFactory<UnwrittenMergedAndroidDataSubject, UnwrittenMergedAndroidData>() { + @Override + public UnwrittenMergedAndroidDataSubject getSubject( + FailureStrategy fs, UnwrittenMergedAndroidData that) { + return new UnwrittenMergedAndroidDataSubject(fs, that); + } + }; + + public UnwrittenMergedAndroidDataSubject( + FailureStrategy failureStrategy, @Nullable UnwrittenMergedAndroidData subject) { + super(failureStrategy, subject); + } + + public void isEqualTo(UnwrittenMergedAndroidData expected) { + UnwrittenMergedAndroidData subject = getSubject(); + if (!Objects.equal(subject, expected)) { + if (subject == null) { + assertThat(subject).isEqualTo(expected); + } + assertThat(subject.getManifest().toString()) + .named("manifest") + .isEqualTo(expected.getManifest().toString()); + + compareDataSets("resources", subject.getPrimary(), expected.getPrimary()); + compareDataSets("deps", subject.getTransitive(), expected.getTransitive()); + } + } + + private void compareDataSets( + String identifier, ParsedAndroidData subject, ParsedAndroidData expected) { + assertThat(subject.getOverwritingResources()) + .named("Overwriting " + identifier) + .containsExactlyEntriesIn(expected.getOverwritingResources()); + assertThat(subject.getCombiningResources()) + .named("Combining " + identifier) + .containsExactlyEntriesIn(expected.getCombiningResources()); + assertThat(subject.getAssets()) + .named("Assets " + identifier) + .containsExactlyEntriesIn(expected.getAssets()); + } + } + + private static final class TestLoggingHandler extends Handler { + public final List<String> warnings = new ArrayList<String>(); + + @Override + public void publish(LogRecord record) { + if (record.getLevel().equals(Level.WARNING)) { + warnings.add(record.getMessage()); + } + } + + @Override + public void flush() {} + + @Override + public void close() throws SecurityException {} + } +} diff --git a/src/test/java/com/google/devtools/build/android/AndroidDataSerializerAndDeserializerTest.java b/src/test/java/com/google/devtools/build/android/AndroidDataSerializerAndDeserializerTest.java new file mode 100644 index 0000000000..c7ac512854 --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/AndroidDataSerializerAndDeserializerTest.java @@ -0,0 +1,362 @@ +// 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; + +import static com.google.devtools.build.android.ParsedAndroidDataBuilder.file; +import static com.google.devtools.build.android.ParsedAndroidDataBuilder.xml; + +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; +import com.google.common.jimfs.Jimfs; +import com.google.common.truth.Truth; +import com.google.devtools.build.android.xml.IdXmlResourceValue; +import com.google.devtools.build.android.xml.ResourcesAttribute; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for the AndroidDataSerializer and AndroidDataDeserializer. */ +@RunWith(JUnit4.class) +public class AndroidDataSerializerAndDeserializerTest { + + private FileSystem fs; + private FullyQualifiedName.Factory fqnFactory; + private Path source; + private Path manifest; + + @Before + public void createCleanEnvironment() throws Exception { + fs = Jimfs.newFileSystem(); + fqnFactory = FullyQualifiedName.Factory.from(ImmutableList.<String>of()); + source = Files.createDirectory(fs.getPath("source")); + manifest = Files.createFile(source.resolve("AndroidManifest.xml")); + } + + @Test + public void serializeAssets() throws Exception { + Path binaryPath = fs.getPath("out.bin"); + AndroidDataSerializer serializer = AndroidDataSerializer.create(); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + manifest, + ParsedAndroidDataBuilder.buildOn(source) + .assets(file().source("hunting/of/the/boojum")) + .build(), + ParsedAndroidDataBuilder.empty()); + expected.serializeTo(serializer); + serializer.flushTo(binaryPath); + + AndroidDataDeserializer deserializer = AndroidDataDeserializer.create(); + TestMapConsumer<DataAsset> assets = TestMapConsumer.ofAssets(); + deserializer.read(binaryPath, KeyValueConsumers.of(null, null, assets)); + Truth.assertThat(assets).isEqualTo(expected.getPrimary().getAssets()); + } + + @Test + public void serializeCombiningResource() throws Exception { + Path binaryPath = fs.getPath("out.bin"); + AndroidDataSerializer serializer = AndroidDataSerializer.create(); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + manifest, + ParsedAndroidDataBuilder.buildOn(source, fqnFactory) + .combining( + xml("id/snark").source("values/ids.xml").value(IdXmlResourceValue.of())) + .build(), + ParsedAndroidDataBuilder.empty()); + expected.serializeTo(serializer); + serializer.flushTo(binaryPath); + + AndroidDataDeserializer deserializer = AndroidDataDeserializer.create(); + TestMapConsumer<DataResource> resources = TestMapConsumer.ofResources(); + deserializer.read( + binaryPath, + KeyValueConsumers.of( + null, // overwriting + resources, // combining + null // assets + )); + Truth.assertThat(resources).isEqualTo(expected.getPrimary().getCombiningResources()); + } + + @Test + public void serializeOverwritingResource() throws Exception { + Path binaryPath = fs.getPath("out.bin"); + AndroidDataSerializer serializer = AndroidDataSerializer.create(); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + manifest, + ParsedAndroidDataBuilder.buildOn(source, fqnFactory) + .overwritable(file("layout/banker").source("layout/banker.xml")) + .build(), + ParsedAndroidDataBuilder.empty()); + expected.serializeTo(serializer); + serializer.flushTo(binaryPath); + + AndroidDataDeserializer deserializer = AndroidDataDeserializer.create(); + TestMapConsumer<DataResource> resources = TestMapConsumer.ofResources(); + deserializer.read( + binaryPath, + KeyValueConsumers.of( + resources, // overwriting + null, // combining + null // assets + )); + Truth.assertThat(resources).isEqualTo(expected.getPrimary().getOverwritingResources()); + } + + @Test + public void serializeFileWithIds() throws Exception { + Path binaryPath = fs.getPath("out.bin"); + AndroidDataSerializer serializer = AndroidDataSerializer.create(); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "layout/some_layout.xml", + AndroidDataBuilder.ResourceType.LAYOUT, + "<TextView android:id=\"@+id/MyTextView\"", + " android:text=\"@string/walrus\"", + " android:layout_width=\"wrap_content\"", + " android:layout_height=\"wrap_content\" />") + // Test what happens if a user accidentally uses the same ID in multiple layouts too. + .addResource( + "layout/another_layout.xml", + AndroidDataBuilder.ResourceType.LAYOUT, + "<TextView android:id=\"@+id/MyTextView\"", + " android:text=\"@string/walrus\"", + " android:layout_width=\"wrap_content\"", + " android:layout_height=\"wrap_content\" />") + // Also check what happens if a value XML file also contains the same ID. + .addResource( + "values/ids.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<item name=\"MyTextView\" type=\"id\"/>", + "<item name=\"OtherId\" type=\"id\"/>") + .addResource( + "values/strings.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<string name=\"walrus\">I has a bucket</string>") + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + manifest, + direct, + ParsedAndroidDataBuilder.empty()); + expected.serializeTo(serializer); + serializer.flushTo(binaryPath); + + AndroidDataDeserializer deserializer = AndroidDataDeserializer.create(); + TestMapConsumer<DataResource> overwriting = TestMapConsumer.ofResources(); + TestMapConsumer<DataResource> combining = TestMapConsumer.ofResources(); + deserializer.read( + binaryPath, + KeyValueConsumers.of( + overwriting, + combining, + null // assets + )); + Truth.assertThat(overwriting).isEqualTo(expected.getPrimary().getOverwritingResources()); + Truth.assertThat(combining).isEqualTo(expected.getPrimary().getCombiningResources()); + } + + @Test + public void serialize() throws Exception { + Path binaryPath = fs.getPath("out.bin"); + AndroidDataSerializer serializer = AndroidDataSerializer.create(); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + manifest, + ParsedAndroidDataBuilder.buildOn(source, fqnFactory) + .overwritable( + file("layout/banker").source("layout/banker.xml"), + xml("<resources>/foo").source("values/ids.xml") + .value(ResourcesAttribute.of("foo", "fooVal"))) + .combining( + xml("id/snark").source("values/ids.xml").value(IdXmlResourceValue.of())) + .assets(file().source("hunting/of/the/boojum")) + .build(), + ParsedAndroidDataBuilder.buildOn(source, fqnFactory) + .overwritable(file("layout/butcher").source("layout/butcher.xml")) + .combining( + xml("id/snark").source("values/ids.xml").value(IdXmlResourceValue.of())) + .assets(file().source("hunting/of/the/snark")) + .build()); + expected.serializeTo(serializer); + serializer.flushTo(binaryPath); + + KeyValueConsumers primary = + KeyValueConsumers.of( + TestMapConsumer.ofResources(), // overwriting + TestMapConsumer.ofResources(), // combining + TestMapConsumer.ofAssets() // assets + ); + + AndroidDataDeserializer deserializer = AndroidDataDeserializer.create(); + deserializer.read(binaryPath, primary); + Truth.assertThat(primary.overwritingConsumer) + .isEqualTo(expected.getPrimary().getOverwritingResources()); + Truth.assertThat(primary.combiningConsumer) + .isEqualTo(expected.getPrimary().getCombiningResources()); + Truth.assertThat(primary.assetConsumer).isEqualTo(expected.getPrimary().getAssets()); + } + + @Test + public void testDeserializeMissing() throws Exception { + Path binaryPath = fs.getPath("out.bin"); + AndroidDataSerializer serializer = AndroidDataSerializer.create(); + UnwrittenMergedAndroidData expected = + UnwrittenMergedAndroidData.of( + manifest, + ParsedAndroidDataBuilder.buildOn(source, fqnFactory) + .overwritable( + file("layout/banker").source("layout/banker.xml"), + xml("<resources>/foo").source("values/ids.xml") + .value(ResourcesAttribute.of("foo", "fooVal"))) + .combining( + xml("id/snark").source("values/ids.xml").value(IdXmlResourceValue.of())) + .assets(file().source("hunting/of/the/boojum")) + .build(), + ParsedAndroidDataBuilder.buildOn(source, fqnFactory) + .overwritable(file("layout/butcher").source("layout/butcher.xml")) + .combining( + xml("id/snark").source("values/ids.xml").value(IdXmlResourceValue.of())) + .assets(file().source("hunting/of/the/snark")) + .build()); + expected.serializeTo(serializer); + serializer.flushTo(binaryPath); + + AndroidDataDeserializer deserializer = + AndroidDataDeserializer.withFilteredResources( + ImmutableList.of("the/boojum", "values/ids.xml", "layout/banker.xml")); + + KeyValueConsumers primary = + KeyValueConsumers.of( + TestMapConsumer.ofResources(), // overwriting + TestMapConsumer.ofResources(), // combining + null // assets + ); + + deserializer.read(binaryPath, primary); + Truth.assertThat(primary.overwritingConsumer).isEqualTo(Collections.emptyMap()); + Truth.assertThat(primary.combiningConsumer).isEqualTo(Collections.emptyMap()); + } + + private static class TestMapConsumer<T extends DataValue> + implements ParsedAndroidData.KeyValueConsumer<DataKey, T>, Map<DataKey, T> { + + Map<DataKey, T> target; + + static TestMapConsumer<DataAsset> ofAssets() { + return new TestMapConsumer<>(new HashMap<DataKey, DataAsset>()); + } + + static TestMapConsumer<DataResource> ofResources() { + return new TestMapConsumer<>(new HashMap<DataKey, DataResource>()); + } + + public TestMapConsumer(Map<DataKey, T> target) { + this.target = target; + } + + @Override + public void consume(DataKey key, T value) { + target.put(key, value); + } + + @Override + public int size() { + return target.size(); + } + + @Override + public boolean isEmpty() { + return target.isEmpty(); + } + + @Override + public boolean containsKey(Object key) { + return target.containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return target.containsValue(value); + } + + @Override + public T get(Object key) { + return target.get(key); + } + + @Override + public T put(DataKey key, T value) { + return target.put(key, value); + } + + @Override + public T remove(Object key) { + return target.remove(key); + } + + @Override + public void putAll(Map<? extends DataKey, ? extends T> m) { + target.putAll(m); + } + + @Override + public void clear() { + target.clear(); + } + + @Override + public Set<DataKey> keySet() { + return target.keySet(); + } + + @Override + public Collection<T> values() { + return target.values(); + } + + @Override + public Set<java.util.Map.Entry<DataKey, T>> entrySet() { + return target.entrySet(); + } + + @Override + public boolean equals(Object o) { + return target.equals(o); + } + + @Override + public int hashCode() { + return target.hashCode(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("target", target).toString(); + } + } +} diff --git a/src/test/java/com/google/devtools/build/android/AndroidDataWriterTest.java b/src/test/java/com/google/devtools/build/android/AndroidDataWriterTest.java new file mode 100644 index 0000000000..e9ee8dcb93 --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/AndroidDataWriterTest.java @@ -0,0 +1,397 @@ +// 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; + +import static com.google.common.truth.Truth.assertAbout; +import static com.google.common.truth.Truth.assertThat; + +import com.android.ide.common.internal.PngCruncher; +import com.android.ide.common.internal.PngException; +import com.google.common.collect.ImmutableMap; +import com.google.common.jimfs.Jimfs; +import com.google.common.truth.FailureStrategy; +import com.google.common.truth.SubjectFactory; +import com.google.common.util.concurrent.MoreExecutors; +import java.io.File; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for the AndroidDataWriter. */ +@RunWith(JUnit4.class) +public class AndroidDataWriterTest { + + private static final String START_RESOURCES = + new String(AndroidDataWriter.PRELUDE) + "<resources"; + private static final String END_RESOURCES = new String(AndroidDataWriter.END_RESOURCES); + + private FileSystem fs; + + @Before + public void createCleanEnvironment() { + fs = Jimfs.newFileSystem(); + } + + @Test + public void writeResourceFile() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidDataWriter mergedDataWriter = AndroidDataWriter.createWithDefaults(target); + String drawable = "drawable/menu.gif"; + String layout = "layout/foo.xml"; + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource(layout, AndroidDataBuilder.ResourceType.LAYOUT, "") + .addResourceBinary(drawable, Files.createFile(fs.getPath("menu.gif"))) + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + MergedAndroidData actual = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()) + .write(mergedDataWriter); + + assertAbout(paths).that(actual.getManifest()).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve(drawable)).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve(layout)).exists(); + } + + @Test + public void writePngInRawAndNotInRaw() throws Exception { + Path tmpDir = Files.createTempDirectory(this.toString()); + final Path target = tmpDir.resolve("target"); + final Path source = tmpDir.resolve("source"); + final String drawable = "drawable/crunch.png"; + final String raw = "raw/nocrunch.png"; + final String layout = "layout/foo.xml"; + AndroidDataWriter mergedDataWriter = + AndroidDataWriter.createWith( + target, + target.resolve("res"), + target.resolve("assets"), + new PngCruncher() { + @Override + public int start() { + return 0; + } + + @Override + public void end(int key) throws InterruptedException { + } + + @Override + public void crunchPng(int key, File from, File to) + throws PngException { + assertThat(from.toString()).doesNotContain(raw); + try { + Files.copy(from.toPath(), to.toPath()); + } catch (IOException e) { + throw new PngException(e); + } + } + }, + MoreExecutors.newDirectExecutorService()); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource(layout, AndroidDataBuilder.ResourceType.LAYOUT, "") + .addResourceBinary(drawable, Files.createFile(tmpDir.resolve("crunch.png"))) + .addResourceBinary(raw, Files.createFile(tmpDir.resolve("nocrunch.png"))) + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + MergedAndroidData actual = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()) + .write(mergedDataWriter); + + assertAbout(paths).that(actual.getManifest()).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve(drawable)).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve(raw)).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve(layout)).exists(); + } + + @Test + public void writeNinepatchResourceFile() throws Exception { + Path tmpDir = Files.createTempDirectory(this.toString()); + Path target = tmpDir.resolve("target"); + Path source = tmpDir.resolve("source"); + AndroidDataWriter mergedDataWriter = AndroidDataWriter.createWithDefaults(target); + String drawable = "drawable-hdpi-v4/seven_eight.9.png"; + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResourceBinary(drawable, Files.createFile(fs.getPath("seven_eight.9.png"))) + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + MergedAndroidData actual = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()) + .write(mergedDataWriter); + + assertAbout(paths).that(actual.getManifest()).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve(drawable)).exists(); + } + + @Test + public void writeResourceXml() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidDataWriter mergedDataWriter = AndroidDataWriter.createWithDefaults(target); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "values/ids.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<item name=\"id1\" type=\"id\"/>", + "<item name=\"id\" type=\"id\"/>") + .addResource( + "values/stubs.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<item name=\"walrus\" type=\"drawable\"/>") + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + MergedAndroidData actual = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()) + .write(mergedDataWriter); + + assertAbout(paths).that(actual.getManifest()).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve("values/values.xml")).exists(); + assertAbout(paths) + .that(actual.getResourceDir().resolve("values/values.xml")) + .xmlContentsIsEqualTo( + START_RESOURCES + ">", + "<!-- source/res/values/stubs.xml --><eat-comment/>", + "<item name='walrus' type='drawable'/>", + "<!-- source/res/values/ids.xml --><eat-comment/>", + "<item name='id' type='id'/>", + "<item name='id1' type='id'/>", + END_RESOURCES); + } + + @Test + public void writeResourceXmlWithQualfiers() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidDataWriter mergedDataWriter = AndroidDataWriter.createWithDefaults(target); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "values/ids.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<item name=\"id1\" type=\"id\"/>") + .addResource( + "values-en/ids.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<item name=\"id1\" type=\"id\"/>") + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + MergedAndroidData actual = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()) + .write(mergedDataWriter); + + assertAbout(paths).that(actual.getManifest()).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve("values/values.xml")).exists(); + assertAbout(paths) + .that(actual.getResourceDir().resolve("values/values.xml")) + .xmlContentsIsEqualTo( + START_RESOURCES + ">", + "<!-- source/res/values/ids.xml --><eat-comment/><item name='id1' type='id'/>", + END_RESOURCES); + assertAbout(paths).that(actual.getResourceDir().resolve("values-en/values.xml")).exists(); + assertAbout(paths) + .that(actual.getResourceDir().resolve("values-en/values.xml")) + .xmlContentsIsEqualTo( + START_RESOURCES + ">", + "<!-- source/res/values-en/ids.xml --><eat-comment/><item name='id1' type='id'/>", + END_RESOURCES); + } + + @Test + public void writePublicResourceSameNameDifferentType() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidDataWriter mergedDataWriter = AndroidDataWriter.createWithDefaults(target); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "values/integers.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<integer name=\"foo\">12345</integer>", + "<public name=\"foo\" type=\"integer\" id=\"0x7f040000\"/>", + "<integer name=\"zoo\">54321</integer>", + "<public name=\"zoo\" type=\"integer\" />") + .addResource( + "values/strings.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<string name=\"foo\">meow</string>", + "<public name=\"foo\" type=\"string\" id=\"0x7f050000\"/>") + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + MergedAndroidData actual = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()) + .write(mergedDataWriter); + + assertAbout(paths).that(actual.getManifest()).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve("values/values.xml")).exists(); + assertAbout(paths) + .that(actual.getResourceDir().resolve("values/values.xml")) + .xmlContentsIsEqualTo( + START_RESOURCES + ">", + "<!-- source/res/values/integers.xml --><eat-comment/>", + "<integer name='foo'>12345</integer>", + "<integer name='zoo'>54321</integer>", + "<!-- source/res/values/strings.xml --><eat-comment/>", + "<string name='foo'>meow</string>", + "<!-- source/res/values/integers.xml --><eat-comment/>", + "<public name='foo' type='integer' id='0x7f040000'/>", + "<public name='foo' type='string' id='0x7f050000'/>", + "<public name='zoo' type='integer' />", + END_RESOURCES); + } + + @Test + public void writeWithIDDuplicates() throws Exception { + // We parse IDs from layout, etc. XML. We can include it in the merged values.xml redundantly + // (redundant because we also give aapt the original layout xml, which it can parse for IDs + // too), but make sure we don't accidentally put multiple copies in the merged values.xml file. + // Otherwise, aapt will throw an error if there are duplicates in the same values.xml file. + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidDataWriter mergedDataWriter = AndroidDataWriter.createWithDefaults(target); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "layout/some_layout.xml", + AndroidDataBuilder.ResourceType.LAYOUT, + "<TextView android:id=\"@+id/MyTextView\"", + " android:text=\"@string/walrus\"", + " android:layout_above=\"@+id/AnotherTextView\"", + " android:layout_width=\"wrap_content\"", + " android:layout_height=\"wrap_content\" />", + // Test redundantly having a "+id/MyTextView" in a different attribute. + "<TextView android:id=\"@id/AnotherTextView\"", + " android:text=\"@string/walrus\"", + " android:layout_below=\"@+id/MyTextView\"", + " android:layout_width=\"wrap_content\"", + " android:layout_height=\"wrap_content\" />") + // Test what happens if a user accidentally uses the same ID in multiple layouts too. + .addResource( + "layout/another_layout.xml", + AndroidDataBuilder.ResourceType.LAYOUT, + "<TextView android:id=\"@+id/MyTextView\"", + " android:text=\"@string/walrus\"", + " android:layout_width=\"wrap_content\"", + " android:layout_height=\"wrap_content\" />") + // Also check what happens if a value XML file also contains the same ID. + .addResource( + "values/ids.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<item name=\"MyTextView\" type=\"id\"/>", + "<item name=\"OtherId\" type=\"id\"/>") + .addResource( + "values/strings.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<string name=\"walrus\">I has a bucket</string>") + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + MergedAndroidData actual = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()) + .write(mergedDataWriter); + + assertAbout(paths).that(actual.getManifest()).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve("layout/some_layout.xml")).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve("values/values.xml")).exists(); + assertAbout(paths) + .that(actual.getResourceDir().resolve("values/values.xml")) + .xmlContentsIsEqualTo( + START_RESOURCES + ">", + "<!-- source/res/values/ids.xml --><eat-comment/>", + "<item name='MyTextView' type='id'/>", + "<item name='OtherId' type='id'/>", + "<!-- source/res/values/strings.xml --><eat-comment/>", + "<string name='walrus'>I has a bucket</string>", + END_RESOURCES); + } + + @Test + public void writeResourceXmlWithAttributes() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidDataWriter mergedDataWriter = AndroidDataWriter.createWithDefaults(target); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addValuesWithAttributes( + "values/ids.xml", + ImmutableMap.of("foo", "fooVal", "bar", "barVal"), + "<item name=\"id1\" type=\"id\"/>", + "<item name=\"id\" type=\"id\"/>") + .addValuesWithAttributes( + "values/stubs.xml", + ImmutableMap.of("baz", "bazVal"), + "<item name=\"walrus\" type=\"drawable\"/>") + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + MergedAndroidData actual = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()) + .write(mergedDataWriter); + + assertAbout(paths).that(actual.getManifest()).exists(); + assertAbout(paths).that(actual.getResourceDir().resolve("values/values.xml")).exists(); + assertAbout(paths) + .that(actual.getResourceDir().resolve("values/values.xml")) + .xmlContentsIsEqualTo( + START_RESOURCES + " foo=\"fooVal\" bar=\"barVal\" baz=\"bazVal\">", + "<!-- source/res/values/stubs.xml --><eat-comment/>", + "<item name='walrus' type='drawable'/>", + "<!-- source/res/values/ids.xml --><eat-comment/>", + "<item name='id' type='id'/>", + "<item name='id1' type='id'/>", + END_RESOURCES); + } + + @Test + public void writeAssetFile() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidDataWriter mergedDataWriter = AndroidDataWriter.createWithDefaults(target); + String asset = "hunting/of/the/boojum"; + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addAsset(asset, "not a snark!") + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + MergedAndroidData actual = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()) + .write(mergedDataWriter); + + assertAbout(paths).that(actual.getManifest()).exists(); + assertAbout(paths).that(actual.getAssetDir().resolve(asset)).exists(); + } + + private static final SubjectFactory<PathsSubject, Path> paths = + new SubjectFactory<PathsSubject, Path>() { + @Override + public PathsSubject getSubject(FailureStrategy failureStrategy, Path path) { + return new PathsSubject(failureStrategy, path); + } + }; +} diff --git a/src/test/java/com/google/devtools/build/android/AndroidResourceClassWriterTest.java b/src/test/java/com/google/devtools/build/android/AndroidResourceClassWriterTest.java new file mode 100644 index 0000000000..72eac32a7f --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/AndroidResourceClassWriterTest.java @@ -0,0 +1,696 @@ +// 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; + +import static com.google.common.truth.Truth.assertAbout; +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.jimfs.Jimfs; +import com.google.common.truth.FailureStrategy; +import com.google.common.truth.SubjectFactory; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +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 AndroidResourceClassWriter}. + */ +@RunWith(JUnit4.class) +public class AndroidResourceClassWriterTest { + + private FileSystem fs; + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private static final AndroidFrameworkAttrIdProvider mockAndroidFrameworkIds = + new MockAndroidFrameworkAttrIdProvider(ImmutableMap.<String, Integer>of()); + + @Before + public void createCleanEnvironment() { + fs = Jimfs.newFileSystem(); + } + + @Test + public void simpleIdFromLayout() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of(mockAndroidFrameworkIds, target, "com.carroll.lewis"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "layout/some_layout.xml", + AndroidDataBuilder.ResourceType.LAYOUT, + "<TextView android:id=\"@+id/HelloView\"", + " android:text=\"Hello World!\"", + " android:layout_width=\"wrap_content\"", + " android:layout_height=\"wrap_content\" />", + "<Button android:id=\"@+id/AdiosButton\"", + " android:text=\"Adios!\"", + " android:layout_width=\"wrap_content\"", + " android:layout_height=\"wrap_content\" />") + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + + assertAbout(paths) + .that(target.resolve("com/carroll/lewis/R.java")) + .javaContentsIsEqualTo( + "package com.carroll.lewis;", + "public final class R {", + "public static final class id {", + "public static int AdiosButton = 0x7f030000;", + "public static int HelloView = 0x7f030001;", + "}", + "public static final class layout {", + "public static int some_layout = 0x7f020000;", + "}", + "}" + ); + assertAbout(paths) + .that(target) + .withClass("com.carroll.lewis.R$id") + .classContentsIsEqualTo( + ImmutableMap.of( + "AdiosButton", 0x7f030000, + "HelloView", 0x7f030001), + ImmutableMap.<String, List<Integer>>of(), + false + ); + assertAbout(paths) + .that(target) + .withClass("com.carroll.lewis.R$layout") + .classContentsIsEqualTo( + ImmutableMap.of("some_layout", 0x7f020000), + ImmutableMap.<String, List<Integer>>of(), + false + ); + } + + @Test + public void ninePatchFieldNames() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + String drawable = "drawable/light.png"; + String ninePatch = "drawable/patchface.9.png"; + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of(mockAndroidFrameworkIds, target, "com.boop"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResourceBinary( + drawable, + Files.createFile(fs.getPath("lightbringer.png"))) + .addResourceBinary( + ninePatch, + Files.createFile(fs.getPath("patchface.9.png"))) + .createManifest("AndroidManifest.xml", "com.boop", "") + .buildParsed(); + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + assertAbout(paths) + .that(target.resolve("com/boop/R.java")) + .javaContentsIsEqualTo( + "package com.boop;", + "public final class R {", + "public static final class drawable {", + "public static int light = 0x7f020000;", + "public static int patchface = 0x7f020001;", + "}", + "}" + ); + assertAbout(paths) + .that(target) + .withClass("com.boop.R$drawable") + .classContentsIsEqualTo( + ImmutableMap.of( + "light", 0x7f020000, + "patchface", 0x7f020001), + ImmutableMap.<String, List<Integer>>of(), + false + ); + } + + @Test + public void unionOfResourcesInConfigurations() throws Exception { + // See what happens if there are some configuration specific resources + // (selection guarded by checks at runtime?). + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + String drawable = "drawable/light.png"; + String drawableV18 = "drawable-v18/light18.png"; + String drawableV19 = "drawable-xxhdpi-v19/light19.png"; + String drawableV20 = "drawable-ldltr-v20/light20.png"; + Path stubImage = Files.createFile(fs.getPath("stub.png")); + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of(mockAndroidFrameworkIds, target, "com.boop"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResourceBinary(drawable, stubImage) + .addResourceBinary(drawableV18, stubImage) + .addResourceBinary(drawableV19, stubImage) + .addResourceBinary(drawableV20, stubImage) + .createManifest("AndroidManifest.xml", "com.boop", "") + .buildParsed(); + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + assertAbout(paths) + .that(target.resolve("com/boop/R.java")) + .javaContentsIsEqualTo( + "package com.boop;", + "public final class R {", + "public static final class drawable {", + "public static int light = 0x7f020000;", + "public static int light18 = 0x7f020001;", + "public static int light19 = 0x7f020002;", + "public static int light20 = 0x7f020003;", + "}", + "}" + ); + assertAbout(paths) + .that(target) + .withClass("com.boop.R$drawable") + .classContentsIsEqualTo( + ImmutableMap.of( + "light", 0x7f020000, + "light18", 0x7f020001, + "light19", 0x7f020002, + "light20", 0x7f020003), + ImmutableMap.<String, List<Integer>>of(), + false + ); + } + + @Test + public void normalizeStyleAndStyleableNames() throws Exception { + // Style and Styleables can have dots in the name. In order for it to be a legal Java + // identifier, the dots are converted to underscore. + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + Path transitive = fs.getPath("transitive"); + + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of(mockAndroidFrameworkIds, target, "com.carroll.lewis"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "values/attr.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<attr name=\"y_color\" format=\"color\" />", + "<attr name=\"z_color\" format=\"color\" />" + ) + .addResource( + "values/style.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<style name=\"YStyle\">", + " <item name=\"y_color\">#FF00FF00</item>", + "</style>", + "<style name=\"ZStyle.ABC\" parent=\"YStyle\">", + " <item name=\"z_color\">#00FFFF00</item>", + "</style>" + ) + .addResource( + "values/styleable.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<declare-styleable name=\"com.google.android.Dots\">", + " <attr name=\"y_color\"/>", + " <attr name=\"z_color\"/>", + " <attr name=\"x_color\"/>", + "</declare-styleable>" + ) + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + + ParsedAndroidData transitiveDep = + AndroidDataBuilder.of(transitive) + .addResource( + "values/attr.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<attr name=\"x_color\" format=\"color\" />" + ) + .addResource( + "values/styleable.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<declare-styleable name=\"com.google.android.Swirls.Fancy\">", + " <attr name=\"z_color\"/>", + " <attr name=\"x_color\"/>", + " <attr name=\"y_color\"/>", + "</declare-styleable>" + ) + .createManifest("AndroidManifest.xml", "com.library", "") + .buildParsed(); + + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), + direct, + transitiveDep); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + + assertAbout(paths) + .that(target.resolve("com/carroll/lewis/R.java")) + .javaContentsIsEqualTo( + "package com.carroll.lewis;", + "public final class R {", + "public static final class attr {", + "public static int x_color = 0x7f010000;", + "public static int y_color = 0x7f010001;", + "public static int z_color = 0x7f010002;", + "}", + "public static final class style {", + "public static int YStyle = 0x7f020000;", + "public static int ZStyle_ABC = 0x7f020001;", + "}", + "public static final class styleable {", + "public static int[] com_google_android_Dots = { 0x7f010000, 0x7f010001, 0x7f010002 };", + "public static int com_google_android_Dots_x_color = 0x0;", + "public static int com_google_android_Dots_y_color = 0x1;", + "public static int com_google_android_Dots_z_color = 0x2;", + "public static int[] com_google_android_Swirls_Fancy =" + + " { 0x7f010000, 0x7f010001, 0x7f010002 };", + "public static int com_google_android_Swirls_Fancy_x_color = 0x0;", + "public static int com_google_android_Swirls_Fancy_y_color = 0x1;", + "public static int com_google_android_Swirls_Fancy_z_color = 0x2;", + "}", + "}" + ); + assertAbout(paths) + .that(target) + .withClass("com.carroll.lewis.R$attr") + .classContentsIsEqualTo( + ImmutableMap.of( + "x_color", 0x7f010000, + "y_color", 0x7f010001, + "z_color", 0x7f010002), + ImmutableMap.<String, List<Integer>>of(), + false + ); + assertAbout(paths) + .that(target) + .withClass("com.carroll.lewis.R$style") + .classContentsIsEqualTo( + ImmutableMap.of( + "YStyle", 0x7f020000, + "ZStyle_ABC", 0x7f020001), + ImmutableMap.<String, List<Integer>>of(), + false + ); + assertAbout(paths) + .that(target) + .withClass("com.carroll.lewis.R$styleable") + .classContentsIsEqualTo( + ImmutableMap.<String, Integer>builder() + .put("com_google_android_Dots_x_color", 0) + .put("com_google_android_Dots_y_color", 1) + .put("com_google_android_Dots_z_color", 2) + .put("com_google_android_Swirls_Fancy_x_color", 0) + .put("com_google_android_Swirls_Fancy_y_color", 1) + .put("com_google_android_Swirls_Fancy_z_color", 2) + .build(), + ImmutableMap.<String, List<Integer>>of( + "com_google_android_Dots", + ImmutableList.of(0x7f010000, 0x7f010001, 0x7f010002), + "com_google_android_Swirls_Fancy", + ImmutableList.of(0x7f010000, 0x7f010001, 0x7f010002) + ), + false + ); + } + + @Test + public void handleAndroidFrameworkAttributes() throws Exception { + // Attributes in the styleable array need to be sorted by integer ID value, so android + // framework attributes need to come before application attributes. + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of( + new MockAndroidFrameworkAttrIdProvider( + ImmutableMap.of( + "textColor", 0x01000000, + "textColorSecondary", 0x01000006, + "textSize", 0x01000010)), + target, + "com.carroll.lewis"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "values/attr.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<attr name=\"aaa\" format=\"boolean\" />", + "<attr name=\"zzz\" format=\"boolean\" />" + ) + .addResource( + "values/style.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<style name=\"YStyle\">", + " <item name=\"android:textSize\">15sp</item>", + " <item name=\"android:textColor\">#ffffff</item>", + " <item name=\"android:textColorSecondary\">#ffffff</item>", + "</style>") + .addResource( + "values/styleable.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<declare-styleable name=\"com.google.android.Dots\">", + " <attr name=\"aaa\"/>", + " <attr name=\"zzz\"/>", + // the android framework attr should be sorted first, even if it's alphabetically + // after the "aaa" attribute. + " <attr name=\"android:textSize\"/>", + " <attr name=\"android:textColor\"/>", + "</declare-styleable>" + ) + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + + assertAbout(paths) + .that(target.resolve("com/carroll/lewis/R.java")) + .javaContentsIsEqualTo( + "package com.carroll.lewis;", + "public final class R {", + "public static final class attr {", + "public static int aaa = 0x7f010000;", + "public static int zzz = 0x7f010001;", + "}", + "public static final class style {", + "public static int YStyle = 0x7f020000;", + "}", + "public static final class styleable {", + "public static int[] com_google_android_Dots = " + + "{ 0x1000000, 0x1000010, 0x7f010000, 0x7f010001 };", + "public static int com_google_android_Dots_android_textColor = 0x0;", + "public static int com_google_android_Dots_android_textSize = 0x1;", + "public static int com_google_android_Dots_aaa = 0x2;", + "public static int com_google_android_Dots_zzz = 0x3;", + "}", + "}" + ); + assertAbout(paths) + .that(target) + .withClass("com.carroll.lewis.R$attr") + .classContentsIsEqualTo( + ImmutableMap.of( + "aaa", 0x7f010000, + "zzz", 0x7f010001), + ImmutableMap.<String, List<Integer>>of(), + false + ); + assertAbout(paths) + .that(target) + .withClass("com.carroll.lewis.R$style") + .classContentsIsEqualTo( + ImmutableMap.of( + "YStyle", 0x7f020000), + ImmutableMap.<String, List<Integer>>of(), + false + ); + assertAbout(paths) + .that(target) + .withClass("com.carroll.lewis.R$styleable") + .classContentsIsEqualTo( + ImmutableMap.of( + "com_google_android_Dots_android_textColor", 0, + "com_google_android_Dots_android_textSize", 1, + "com_google_android_Dots_aaa", 2, + "com_google_android_Dots_zzz", 3 + ), + ImmutableMap.<String, List<Integer>>of( + "com_google_android_Dots", + ImmutableList.of(0x01000000, 0x01000010, 0x7f010000, 0x7f010001) + ), + false + ); + } + + @Test + public void missingFrameworkAttribute() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of( + new MockAndroidFrameworkAttrIdProvider(ImmutableMap.<String, Integer>of()), + target, + "com.carroll.lewis"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "values/attr.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<attr name=\"aaazzz\" format=\"boolean\" />" + ) + .addResource( + "values/styleable.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<declare-styleable name=\"com.google.android.Dots\">", + " <attr name=\"aaazzz\"/>", + " <attr name=\"android:aaazzz\"/>", + "</declare-styleable>" + ) + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()); + thrown.expect(IOException.class); + thrown.expectMessage("Android attribute not found: aaazzz"); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + } + + @Test + public void missingAppAttribute() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of( + new MockAndroidFrameworkAttrIdProvider(ImmutableMap.<String, Integer>of()), + target, + "com.carroll.lewis"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "values/styleable.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<declare-styleable name=\"com.google.android.Dots\">", + " <attr name=\"aaazzz\"/>", + "</declare-styleable>" + ) + .createManifest("AndroidManifest.xml", "com.carroll.lewis", "") + .buildParsed(); + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()); + thrown.expect(IOException.class); + thrown.expectMessage("App attribute not found: aaazzz"); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + } + + /** + * Test what happens if we try to create a field name that is not a valid Java identifier. Here, + * we start the field name with a number, which is not legal according to {@link + * Character#isJavaIdentifierStart}. + * + * See: {@link com.android.ide.common.res2.FileResourceNameValidator}, and {@link + * com.android.ide.common.res2.ValueResourceNameValidator}. + * + * AAPT seems to miss out on checking this case (it only checks for [a-z0-9_.], but isn't + * position-sensitive). + */ + @Test + public void illegalFileResFieldNamesStart() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + String drawable = "drawable/1.png"; + assertThat(Character.isJavaIdentifierStart('1')).isFalse(); + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of(mockAndroidFrameworkIds, target, "com.boop"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResourceBinary( + drawable, + Files.createFile(fs.getPath("1.png"))) + .createManifest("AndroidManifest.xml", "com.boop", "") + .buildParsed(); + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + assertAbout(paths) + .that(target.resolve("com/boop/R.java")) + .javaContentsIsEqualTo( + "package com.boop;", + "public final class R {", + "public static final class drawable {", + "public static int 1 = 0x7f020000;", + "}", + "}" + ); + assertAbout(paths) + .that(target) + .withClass("com.boop.R$drawable") + .classContentsIsEqualTo( + ImmutableMap.of("1", 0x7f020000), + ImmutableMap.<String, List<Integer>>of(), + false + ); + } + + /** + * Test embedding a character that doesn't satisfy Character#isJavaIdentifierPart. Do so in a file + * resource. In this case, AAPT will actually complain, so we may not need to do earlier + * validation. + */ + @Test + public void illegalFileResFieldNamesCharacters() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + String drawable = "drawable/c++.png"; + assertThat(Character.isJavaIdentifierStart('c')).isTrue(); + assertThat(Character.isJavaIdentifierPart('+')).isFalse(); + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of(mockAndroidFrameworkIds, target, "com.boop"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResourceBinary( + drawable, + Files.createFile(fs.getPath("phone#.png"))) + .createManifest("AndroidManifest.xml", "com.boop", "") + .buildParsed(); + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + assertAbout(paths) + .that(target.resolve("com/boop/R.java")) + .javaContentsIsEqualTo( + "package com.boop;", + "public final class R {", + "public static final class drawable {", + "public static int c++ = 0x7f020000;", + "}", + "}" + ); + assertAbout(paths) + .that(target) + .withClass("com.boop.R$drawable") + .classContentsIsEqualTo( + ImmutableMap.of("c++", 0x7f020000), + ImmutableMap.<String, List<Integer>>of(), + false + ); + } + + /** + * Test embedding a character that doesn't satisfy Character#isJavaIdentifierPart. Do so in a + * value resource. This is a case that AAPT doesn't validate, so it may pass through to the java + * compiler. + */ + @Test + public void illegalValueResFieldNamesCharacters() throws Exception { + Path target = fs.getPath("target"); + Path source = fs.getPath("source"); + assertThat(Character.isJavaIdentifierStart('c')).isTrue(); + assertThat(Character.isJavaIdentifierPart('+')).isFalse(); + AndroidResourceClassWriter resourceClassWriter = + AndroidResourceClassWriter.of(mockAndroidFrameworkIds, target, "com.boop"); + ParsedAndroidData direct = + AndroidDataBuilder.of(source) + .addResource( + "values/integers.xml", + AndroidDataBuilder.ResourceType.VALUE, + "<integer name=\"c++\">0xd</integer>" + ) + .createManifest("AndroidManifest.xml", "com.boop", "") + .buildParsed(); + UnwrittenMergedAndroidData unwrittenMergedAndroidData = + UnwrittenMergedAndroidData.of( + source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty()); + unwrittenMergedAndroidData + .writeResourceClass(resourceClassWriter); + assertAbout(paths) + .that(target.resolve("com/boop/R.java")) + .javaContentsIsEqualTo( + "package com.boop;", + "public final class R {", + "public static final class integer {", + "public static int c++ = 0x7f020000;", + "}", + "}" + ); + assertAbout(paths) + .that(target) + .withClass("com.boop.R$integer") + .classContentsIsEqualTo( + ImmutableMap.of("c++", 0x7f020000), + ImmutableMap.<String, List<Integer>>of(), + false + ); + } + + private static class MockAndroidFrameworkAttrIdProvider + implements AndroidFrameworkAttrIdProvider { + + private final Map<String, Integer> mapToUse; + + MockAndroidFrameworkAttrIdProvider(Map<String, Integer> mapToUse) { + this.mapToUse = mapToUse; + } + + @Override + public int getAttrId(String fieldName) throws AttrLookupException { + if (mapToUse.containsKey(fieldName)) { + return mapToUse.get(fieldName); + } + throw new AttrLookupException("Android attribute not found: " + fieldName); + } + } + + private static final SubjectFactory<ClassPathsSubject, Path> paths = + new SubjectFactory<ClassPathsSubject, Path>() { + @Override + public ClassPathsSubject getSubject(FailureStrategy failureStrategy, Path path) { + return new ClassPathsSubject(failureStrategy, path); + } + }; +} diff --git a/src/test/java/com/google/devtools/build/android/BUILD b/src/test/java/com/google/devtools/build/android/BUILD index 411d0f3bb2..11d14b9b75 100644 --- a/src/test/java/com/google/devtools/build/android/BUILD +++ b/src/test/java/com/google/devtools/build/android/BUILD @@ -21,6 +21,50 @@ java_test( ) java_test( + name = "AndroidDataMergerTest", + srcs = ["AndroidDataMergerTest.java"], + tags = ["no_windows"], # Test asserts forward slashes in android data xml files. + deps = [ + ":test_utils", + "//src/tools/android/java/com/google/devtools/build/android:android_builder_lib", + "//third_party:android_common_25_0_0", + "//third_party:guava", + "//third_party:jimfs", + "//third_party:junit4", + "//third_party:truth", + ], +) + +java_test( + name = "AndroidDataWriterTest", + srcs = ["AndroidDataWriterTest.java"], + tags = ["no_windows"], # Test asserts forward slashes in android data xml files. + deps = [ + ":test_utils", + "//src/tools/android/java/com/google/devtools/build/android:android_builder_lib", + "//third_party:android_common_25_0_0", + "//third_party:guava", + "//third_party:jimfs", + "//third_party:junit4", + "//third_party:truth", + ], +) + +java_test( + name = "AndroidResourceClassWriterTest", + srcs = ["AndroidResourceClassWriterTest.java"], + tags = ["no_windows"], # Test asserts forward slashes in android data xml files. + deps = [ + ":test_utils", + "//src/tools/android/java/com/google/devtools/build/android:android_builder_lib", + "//third_party:guava", + "//third_party:jimfs", + "//third_party:junit4", + "//third_party:truth", + ], +) + +java_test( name = "ConvertersTest", size = "small", srcs = ["ConvertersTest.java"], @@ -32,3 +76,18 @@ java_test( "//third_party:truth", ], ) + +java_library( + name = "test_utils", + testonly = 1, + srcs = glob( + ["*.java"], + exclude = ["*Test.java"], + ), + deps = [ + "//src/tools/android/java/com/google/devtools/build/android:android_builder_lib", + "//third_party:android_common_25_0_0", + "//third_party:guava", + "//third_party:truth", + ], +) diff --git a/src/test/java/com/google/devtools/build/android/ClassPathsSubject.java b/src/test/java/com/google/devtools/build/android/ClassPathsSubject.java new file mode 100644 index 0000000000..3bc266c126 --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/ClassPathsSubject.java @@ -0,0 +1,165 @@ +// 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; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.base.Function; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.truth.FailureStrategy; +import com.google.common.truth.Subject; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; + +/** + * A testing utility that allows .java/.class related assertions against Paths. + */ +public class ClassPathsSubject extends Subject<ClassPathsSubject, Path> { + + ClassPathsSubject(FailureStrategy failureStrategy, @Nullable Path subject) { + super(failureStrategy, subject); + } + + void exists() { + if (getSubject() == null) { + fail("should not be null."); + } + if (!Files.exists(getSubject())) { + fail("exists."); + } + } + + /** + * Check that the contents of the java file at the current path, is equivalent to the given + * expected contents (modulo header comments and surrounding whitespace). + * + * @param contents expected contents + */ + public void javaContentsIsEqualTo(String... contents) { + if (getSubject() == null) { + fail("should not be null."); + } + exists(); + try { + assertThat( + trimWhitespace( + stripJavaHeaderComments( + Files.readAllLines(getSubject(), StandardCharsets.UTF_8)))) + .containsExactly((Object[]) contents) + .inOrder(); + } catch (IOException e) { + fail(e.toString()); + } + } + + private List<String> stripJavaHeaderComments(List<String> strings) { + List<String> result = new ArrayList<>(); + boolean inComment = false; + for (String string : strings) { + if (string.trim().startsWith("/*")) { + inComment = true; + continue; + } + if (inComment) { + if (string.trim().startsWith("*/")) { + inComment = false; + continue; + } + continue; + } + result.add(string); + } + return result; + } + + private List<String> trimWhitespace(List<String> strings) { + return Lists.transform(strings, new Function<String, String>() { + @Override + public String apply(String s) { + return s.trim(); + } + }); + } + + /** + * Check the class file with the given name, assuming the current path is part of the classpath. + * + * @param className the fully qualified class name + */ + public ClassNameSubject withClass(String className) { + if (getSubject() == null) { + fail("should not be null."); + } + exists(); + return new ClassNameSubject(failureStrategy, getSubject(), className); + } + + static final class ClassNameSubject extends Subject<ClassNameSubject, String> { + + private final Path basePath; + + public ClassNameSubject( + FailureStrategy failureStrategy, Path basePath, String subject) { + super(failureStrategy, subject); + this.basePath = basePath; + } + + public void classContentsIsEqualTo( + ImmutableMap<String, Integer> intFields, + ImmutableMap<String, List<Integer>> intArrayFields, + boolean areFieldsFinal) throws Exception { + String expectedClassName = getSubject(); + URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{basePath.toUri().toURL()}); + Class<?> innerClass = urlClassLoader.loadClass(expectedClassName); + assertThat(innerClass.getSuperclass()).isEqualTo(Object.class); + assertThat(innerClass.getEnclosingClass().toString()) + .endsWith(expectedClassName.substring(0, expectedClassName.indexOf('$'))); + ImmutableMap.Builder<String, Integer> actualIntFields = ImmutableMap.builder(); + ImmutableMap.Builder<String, List<Integer>> actualIntArrayFields = ImmutableMap.builder(); + for (Field f : innerClass.getFields()) { + int fieldModifiers = f.getModifiers(); + assertThat(Modifier.isFinal(fieldModifiers)).isEqualTo(areFieldsFinal); + assertThat(Modifier.isPublic(fieldModifiers)).isTrue(); + assertThat(Modifier.isStatic(fieldModifiers)).isTrue(); + + Class<?> fieldType = f.getType(); + if (fieldType.isPrimitive()) { + assertThat(fieldType).isEqualTo(Integer.TYPE); + actualIntFields.put(f.getName(), (Integer) f.get(null)); + } else { + assertThat(fieldType.isArray()).isTrue(); + int[] asArray = (int[]) f.get(null); + ImmutableList.Builder<Integer> list = ImmutableList.builder(); + for (int i : asArray) { + list.add(i); + } + actualIntArrayFields.put(f.getName(), list.build()); + } + } + assertThat(actualIntFields.build()).containsExactlyEntriesIn(intFields).inOrder(); + assertThat(actualIntArrayFields.build()).containsExactlyEntriesIn(intArrayFields).inOrder(); + } + } +} diff --git a/src/test/java/com/google/devtools/build/android/DataResourceXmlTest.java b/src/test/java/com/google/devtools/build/android/DataResourceXmlTest.java new file mode 100644 index 0000000000..653e7b7044 --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/DataResourceXmlTest.java @@ -0,0 +1,1325 @@ +// 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; + +import static com.google.common.truth.Truth.assertAbout; +import static com.google.common.truth.Truth.assertThat; + +import com.android.ide.common.res2.MergingException; +import com.android.resources.ResourceType; +import com.google.common.base.Function; +import com.google.common.base.Joiner; +import com.google.common.base.Optional; +import com.google.common.collect.FluentIterable; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.MoreFiles; +import com.google.common.io.RecursiveDeleteOption; +import com.google.common.jimfs.Jimfs; +import com.google.common.truth.FailureStrategy; +import com.google.common.truth.SubjectFactory; +import com.google.devtools.build.android.xml.ArrayXmlResourceValue; +import com.google.devtools.build.android.xml.ArrayXmlResourceValue.ArrayType; +import com.google.devtools.build.android.xml.AttrXmlResourceValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.BooleanResourceXmlAttrValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.ColorResourceXmlAttrValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.DimensionResourceXmlAttrValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.EnumResourceXmlAttrValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.FlagResourceXmlAttrValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.FloatResourceXmlAttrValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.FractionResourceXmlAttrValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.IntegerResourceXmlAttrValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.ReferenceResourceXmlAttrValue; +import com.google.devtools.build.android.xml.AttrXmlResourceValue.StringResourceXmlAttrValue; +import com.google.devtools.build.android.xml.IdXmlResourceValue; +import com.google.devtools.build.android.xml.PluralXmlResourceValue; +import com.google.devtools.build.android.xml.PublicXmlResourceValue; +import com.google.devtools.build.android.xml.SimpleXmlResourceValue; +import com.google.devtools.build.android.xml.StyleXmlResourceValue; +import com.google.devtools.build.android.xml.StyleableXmlResourceValue; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import javax.xml.stream.XMLStreamException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link DataResourceXml}. */ +@RunWith(JUnit4.class) +public class DataResourceXmlTest { + static final ImmutableMap<String, String> XLIFF_NAMESPACES = + ImmutableMap.of("xliff", "urn:oasis:names:tc:xliff:document:1.2"); + static final String END_RESOURCES = new String(AndroidDataWriter.END_RESOURCES); + + private FullyQualifiedName.Factory fqnFactory; + private FileSystem fs; + + @Before + public void createCleanEnvironment() { + fs = Jimfs.newFileSystem(); + fqnFactory = FullyQualifiedName.Factory.from(ImmutableList.<String>of()); + } + + private Path writeResourceXml(String... xml) throws IOException { + return writeResourceXml(ImmutableMap.<String, String>of(), ImmutableMap.<String, String>of(), + xml); + } + + private Path writeResourceXml(Map<String, String> namespaces, Map<String, String> attributes, + String... xml) throws IOException { + Path values = fs.getPath("root/values/values.xml"); + Files.createDirectories(values.getParent()); + StringBuilder builder = new StringBuilder(); + builder.append(AndroidDataWriter.PRELUDE).append("<resources"); + for (Entry<String, String> entry : namespaces.entrySet()) { + builder + .append(" xmlns:") + .append(entry.getKey()) + .append("=\"") + .append(entry.getValue()) + .append("\""); + } + for (Entry<String, String> entry : attributes.entrySet()) { + builder + .append(" ") + .append(entry.getKey()) + .append("=\"") + .append(entry.getValue()) + .append("\""); + } + builder.append(">"); + Joiner.on("\n").appendTo(builder, xml); + builder.append(END_RESOURCES); + Files.write(values, builder.toString().getBytes(StandardCharsets.UTF_8)); + return values; + } + + private void parseResourcesFrom( + Path path, Map<DataKey, DataResource> toOverwrite, Map<DataKey, DataResource> toCombine) + throws XMLStreamException, IOException { + DataResourceXml.parse( + XmlResourceValues.getXmlInputFactory(), + path, + fqnFactory, + new FakeConsumer(toOverwrite), + new FakeConsumer(toCombine)); + } + + private FullyQualifiedName fqn(String raw) { + return fqnFactory.parse(raw); + } + + @Test + public void simpleXmlResources() throws Exception { + Path path = + writeResourceXml( + "<string name='exit' description=\"& egress -> "\">way out</string>", + "<bool name='canExit'>false</bool>", + "<color name='exitColor'>#FF000000</color>", + "<dimen name='exitSize'>20sp</dimen>", + "<integer name='exitInt'>20</integer>", + "<fraction name='exitFraction'>%20</fraction>", + "<drawable name='exitBackground'>#99000000</drawable>", + "<item name='some_id' type='id'/>", + "<item name='reference_id' type='id'>@id/some_id</item>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("string/exit"), // Key + DataResourceXml.createWithNoNamespace( + path, + SimpleXmlResourceValue.of( + SimpleXmlResourceValue.Type.STRING, + ImmutableMap.of("description", "& egress -> ""), + "way out")), // Value + fqn("bool/canExit"), // Key + DataResourceXml.createWithNoNamespace( + path, + SimpleXmlResourceValue.createWithValue( + SimpleXmlResourceValue.Type.BOOL, "false")), // Value + fqn("color/exitColor"), // Key + DataResourceXml.createWithNoNamespace( + path, + SimpleXmlResourceValue.createWithValue( + SimpleXmlResourceValue.Type.COLOR, "#FF000000")), // Value + fqn("dimen/exitSize"), // Key + DataResourceXml.createWithNoNamespace( + path, + SimpleXmlResourceValue.createWithValue( + SimpleXmlResourceValue.Type.DIMEN, "20sp")), // Value + fqn("integer/exitInt"), // Key + DataResourceXml.createWithNoNamespace( + path, + SimpleXmlResourceValue.createWithValue( + SimpleXmlResourceValue.Type.INTEGER, "20")), // Value + fqn("fraction/exitFraction"), // Key + DataResourceXml.createWithNoNamespace( + path, + SimpleXmlResourceValue.createWithValue( + SimpleXmlResourceValue.Type.FRACTION, "%20")), // Value + fqn("drawable/exitBackground"), // Key + DataResourceXml.createWithNoNamespace( + path, + SimpleXmlResourceValue.createWithValue( + SimpleXmlResourceValue.Type.DRAWABLE, "#99000000")) // Value + ); + assertThat(toCombine) + .containsExactly( + fqn("id/some_id"), // Key + DataResourceXml.createWithNoNamespace(path, IdXmlResourceValue.of()), // Value + fqn("id/reference_id"), // Key + DataResourceXml.createWithNoNamespace( + path, IdXmlResourceValue.of("@id/some_id")) // Value + ); + } + + @Test + public void skipIgnored() throws Exception { + Path path = writeResourceXml("<skip/>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite).isEmpty(); + assertThat(toCombine).isEmpty(); + } + + @Test + public void eatCommentIgnored() throws Exception { + Path path = writeResourceXml("<eat-comment/>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite).isEmpty(); + assertThat(toCombine).isEmpty(); + } + + @Test + public void itemSimpleXmlResources() throws Exception { + Path path = + writeResourceXml( + "<item type='dimen' name='exitSizePercent'>20%</item>", + "<item type='dimen' format='float' name='exitSizeFloat'>20.0</item>", + "<item type='fraction' name='denom'>%5</item>", + "<item name='subtype_id' type='id'>0x6f972360</item>", + "<item type='array' name='oboes'/>", + "<item type='drawable' name='placeholder'/>"); + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("dimen/exitSizePercent"), // Key + DataResourceXml.createWithNoNamespace( + path, SimpleXmlResourceValue.itemWithValue(ResourceType.DIMEN, "20%")), // Value + fqn("dimen/exitSizeFloat"), // Key + DataResourceXml.createWithNoNamespace( + path, + SimpleXmlResourceValue.itemWithFormattedValue( + ResourceType.DIMEN, "float", "20.0")), // Value + fqn("drawable/placeholder"), // Key + DataResourceXml.createWithNoNamespace( + path, SimpleXmlResourceValue.itemPlaceHolderFor(ResourceType.DRAWABLE)), // Value + fqn("array/oboes"), // Key + DataResourceXml.createWithNoNamespace( + path, SimpleXmlResourceValue.itemPlaceHolderFor(ResourceType.ARRAY)), // Value + fqn("fraction/denom"), // Key + DataResourceXml.createWithNoNamespace( + path, SimpleXmlResourceValue.itemWithValue(ResourceType.FRACTION, "%5")) // Value + ); + } + + @Test + public void styleableXmlResourcesEnum() throws Exception { + Path path = + writeResourceXml( + "<declare-styleable name='Theme'>", + " <attr name=\"labelPosition\" format=\"enum\">", + " <enum name=\"left\" value=\"0\"/>", + " <enum name=\"right\" value=\"1\"/>", + " </attr>", + "</declare-styleable>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("attr/labelPosition"), // Key + DataResourceXml.createWithNoNamespace( + path, + AttrXmlResourceValue.fromFormatEntries( + EnumResourceXmlAttrValue.asEntryOf("left", "0", "right", "1"))) // Value + ); + assertThat(toCombine) + .containsExactly( + fqn("styleable/Theme"), // Key + DataResourceXml.createWithNoNamespace( + path, + StyleableXmlResourceValue.createAllAttrAsDefinitions( + fqnFactory.parse("attr/labelPosition"))) // Value + ); + } + + @Test + public void styleableXmlResourcesString() throws Exception { + Path path = + writeResourceXml( + "<declare-styleable name='UnusedStyleable'>", + " <attr name='attribute1'/>", + " <attr name='attribute2'/>", + "</declare-styleable>", + "<attr name='attribute1' format='string'/>", + "<attr name='attribute2' format='string'/>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("attr/attribute1"), // Key + DataResourceXml.createWithNoNamespace( + path, + AttrXmlResourceValue.fromFormatEntries( + StringResourceXmlAttrValue.asEntry())), // Value + fqn("attr/attribute2"), // Key + DataResourceXml.createWithNoNamespace( + path, + AttrXmlResourceValue.fromFormatEntries( + StringResourceXmlAttrValue.asEntry())) // Value + ); + assertThat(toCombine) + .containsExactly( + fqn("styleable/UnusedStyleable"), // Key + DataResourceXml.createWithNoNamespace( + path, + StyleableXmlResourceValue.createAllAttrAsReferences( + fqnFactory.parse("attr/attribute1"), + fqnFactory.parse("attr/attribute2"))) // Value + ); + } + + @Test + public void pluralXmlResources() throws Exception { + Path path = + writeResourceXml( + "<plurals name='numberOfSongsAvailable'>", + " <item quantity='one'>%d song found.</item>", + " <item quantity='other'>%d songs found.</item>", + "</plurals>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("plurals/numberOfSongsAvailable"), // Key + DataResourceXml.createWithNoNamespace( + path, + PluralXmlResourceValue.createWithoutAttributes( + ImmutableMap.of( + "one", "%d song found.", + "other", "%d songs found."))) // Value + ); + } + + @Test + public void pluralXmlResourcesWithTrailingCharacters() throws Exception { + Path path = + writeResourceXml( + "<plurals name='numberOfSongsAvailable'>", + " <item quantity='one'>%d song found.</item> // this is an invalid comment.", + " <item quantity='other'>%d songs found.</item>typo", + "</plurals>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("plurals/numberOfSongsAvailable"), // Key + DataResourceXml.createWithNoNamespace( + path, + PluralXmlResourceValue.createWithoutAttributes( + ImmutableMap.of( + "one", "%d song found.", + "other", "%d songs found."))) // Value + ); + } + + @Test + public void styleResources() throws Exception { + Path path = + writeResourceXml( + "<style name=\"candlestick_maker\" parent=\"@style/wax_maker\">\n" + + " <item name=\"texture\">waxy</item>\n" + + "</style>"); + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("style/candlestick_maker"), // Key + DataResourceXml.createWithNoNamespace( + path, + StyleXmlResourceValue.of( + "@style/wax_maker", ImmutableMap.of("texture", "waxy"))) // Value + ); + } + + @Test + public void styleResourcesNoParent() throws Exception { + Path path = + writeResourceXml( + "<style name=\"CandlestickMaker\">\n" + + " <item name=\"texture\">waxy</item>\n" + + "</style>"); + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("style/CandlestickMaker"), // Key + DataResourceXml.createWithNoNamespace( + path, StyleXmlResourceValue.of(null, ImmutableMap.of("texture", "waxy"))) // Value + ); + } + + @Test + public void styleResourcesForceNoParent() throws Exception { + Path path = + writeResourceXml( + // using a '.' implies a parent Candlestick, parent='' corrects to no parent. + "<style name=\"Candlestick.Maker\" parent=\"\">\n" + + " <item name=\"texture\">waxy</item>\n" + + "</style>"); + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("style/Candlestick.Maker"), // Key + DataResourceXml.createWithNoNamespace( + path, StyleXmlResourceValue.of("", ImmutableMap.of("texture", "waxy"))) // Value + ); + } + + @Test + public void styleResourcesLazyReference() throws Exception { + Path path = + writeResourceXml( + "<style name=\"candlestick_maker\" parent=\"AppTheme\">\n" + + " <item name=\"texture\">waxy</item>\n" + + "</style>"); + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("style/candlestick_maker"), // Key + DataResourceXml.createWithNoNamespace( + path, + StyleXmlResourceValue.of("AppTheme", ImmutableMap.of("texture", "waxy"))) // Value + ); + } + + @Test + public void arrayXmlResources() throws Exception { + Path path = + writeResourceXml( + "<array name='icons'>", + " <item>@drawable/home</item>", + " <item>@drawable/settings</item>", + " <item>@drawable/logout</item>", + "</array>", + "<array name='colors'>", + " <item>#FFFF0000</item>", + " <item>#FF00FF00</item>", + " <item>#FF0000FF</item>", + "</array>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("array/icons"), // Key + DataResourceXml.createWithNoNamespace( + path, + ArrayXmlResourceValue.of( + ArrayType.ARRAY, + "@drawable/home", + "@drawable/settings", + "@drawable/logout")), // Value + fqn("array/colors"), // Key + DataResourceXml.createWithNoNamespace( + path, + ArrayXmlResourceValue.of( + ArrayType.ARRAY, "#FFFF0000", "#FF00FF00", "#FF0000FF")) // Value + ); + } + + @Test + public void stringArrayXmlResources() throws Exception { + Path path = + writeResourceXml( + "<string-array name='characters'>", + " <item>boojum</item>", + " <item>snark</item>", + " <item>bellman</item>", + " <item>barrister</item>", + " <item>\\\"billiard-marker\\\"</item>", + "</string-array>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("array/characters"), + DataResourceXml.createWithNoNamespace( + path, + ArrayXmlResourceValue.of( + ArrayType.STRING_ARRAY, + "boojum", + "snark", + "bellman", + "barrister", + "\\\"billiard-marker\\\""))); + assertThat(toCombine).isEmpty(); + } + + @Test + public void integerArrayXmlResources() throws Exception { + Path path = + writeResourceXml( + "<integer-array name='bits'>", + " <item>4</item>", + " <item>8</item>", + " <item>16</item>", + " <item>32</item>", + "</integer-array>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("array/bits"), // Key + DataResourceXml.createWithNoNamespace( + path, + ArrayXmlResourceValue.of(ArrayType.INTEGER_ARRAY, "4", "8", "16", "32")) // Value + ); + } + + @Test + public void attrFlagXmlResources() throws Exception { + Path path = + writeResourceXml( + " <attr name=\"labelPosition\">", + " <flag name=\"left\" value=\"0\"/>", + " <flag name=\"right\" value=\"1\"/>", + " </attr>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("attr/labelPosition"), // Key + DataResourceXml.createWithNoNamespace( + path, + AttrXmlResourceValue.fromFormatEntries( + FlagResourceXmlAttrValue.asEntryOf( + "left", "0", + "right", "1"))) // Value + ); + } + + @Test + public void attrMultiFormatImplicitFlagXmlResources() throws Exception { + Path path = + writeResourceXml( + " <attr name='labelPosition' format='reference'>", + " <flag name='left' value='0'/>", + " <flag name='right' value='1'/>", + " </attr>"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("attr/labelPosition"), // Key + DataResourceXml.createWithNoNamespace( + path, + AttrXmlResourceValue.fromFormatEntries( + ReferenceResourceXmlAttrValue.asEntry(), + FlagResourceXmlAttrValue.asEntryOf( + "left", "0", + "right", "1"))) // Value + ); + } + + @Test + public void attrMultiFormatResources() throws Exception { + Path path = + writeResourceXml( + "<attr name='labelPosition' ", + "format='color|boolean|dimension|float|integer|string|fraction|reference' />"); + + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + + assertThat(toOverwrite) + .containsExactly( + fqn("attr/labelPosition"), // Key + DataResourceXml.createWithNoNamespace( + path, + AttrXmlResourceValue.fromFormatEntries( + ColorResourceXmlAttrValue.asEntry(), + BooleanResourceXmlAttrValue.asEntry(), + ReferenceResourceXmlAttrValue.asEntry(), + DimensionResourceXmlAttrValue.asEntry(), + FloatResourceXmlAttrValue.asEntry(), + IntegerResourceXmlAttrValue.asEntry(), + StringResourceXmlAttrValue.asEntry(), + FractionResourceXmlAttrValue.asEntry())) // Value + ); + } + + @Test + public void publicXmlResource() throws Exception { + Path path = + writeResourceXml( + "<string name='exit'>way out</string>", + "<public type='string' name='exit' id='0x123'/>"); + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + assertThat(toOverwrite) + .containsExactly( + fqn("string/exit"), + DataResourceXml.createWithNoNamespace( + path, + SimpleXmlResourceValue.createWithValue( + SimpleXmlResourceValue.Type.STRING, "way out"))); + assertThat(toCombine) + .containsExactly( + fqn("public/exit"), + DataResourceXml.createWithNoNamespace( + path, PublicXmlResourceValue.create(ResourceType.STRING, Optional.of(0x123)))); + } + + @Test + public void writeSimpleXmlResources() throws Exception { + Path source = + writeResourceXml( + "<string name='exit'>way <a href=\"out.html\">out</a></string>", + "<bool name='canExit'>false</bool>", + "<color name='exitColor'>#FF000000</color>", + "<integer name='exitInt'>5</integer>", + "<drawable name='reference'/>"); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("string/exit"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom( + source, "<string name='exit'>way <a href=\"out.html\">out</a></string>")); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("bool/canExit"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, "<bool name='canExit'>false</bool>")); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("color/exitColor"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom(source, "<color name='exitColor'>#FF000000</color>")); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("integer/exitInt"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, "<integer name='exitInt'>5</integer>")); + } + + @Test + public void writeItemResources() throws Exception { + Path source = + writeResourceXml( + "<item type='dimen' name='exitSizePercent'>20%</item>", + "<item type='dimen' format='float' name='exitSizeFloat'>20.0</item>", + "<item name='exitId' type='id'/>", + "<item name='frac' type='fraction'>5%</item>"); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("dimen/exitSizePercent"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom(source, "<item type='dimen' name='exitSizePercent'>20%</item>")); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("dimen/exitSizeFloat"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom( + source, "<item type='dimen' format='float' name='exitSizeFloat'>20.0</item>")); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("fraction/frac"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom(source, "<item name='frac' type='fraction'>5%</item>")); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("id/exitId"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, "<item name='exitId' type='id'/>")); + } + + @Test + public void writeStringResourceWithXliffNamespace() throws Exception { + Path source = fs.getPath("root/values/values.xml"); + Files.createDirectories(source.getParent()); + Files.write( + source, + ("<resources xmlns:xliff=\"urn:oasis:names:tc:xliff:document:1.2\">" + + "<string name=\"star_rating\">Check out our 5\n" + + " <xliff:g id=\"star\">\\u2605</xliff:g>\n" + + "</string>" + + "</resources>") + .getBytes(StandardCharsets.UTF_8)); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("string/star_rating"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom( + XLIFF_NAMESPACES, + ImmutableMap.<String, String>of(), + source, + "<string name=\"star_rating\">Check out our 5", + " <xliff:g id=\"star\">\\u2605</xliff:g>", + "</string>")); + } + + @Test + public void writeStringResourceCData() throws Exception { + String[] xml = { + "<string name='cdata'><![CDATA[<b>Jabber, Jabber</b><br><br>\n Wock!]]></string>" + }; + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("string/cdata"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writeStringResourceWithNamespace() throws Exception { + Path source = fs.getPath("root/values/values.xml"); + Files.createDirectories(source.getParent()); + Files.write( + source, + ("<resources xmlns:ns1=\"urn:oasis:names:tc:xliff:document:1.2\">" + + "<string name=\"star_rating\">Check out our 5\n" + + " <ns1:g xmlns:foglebert=\"defogle\" foglebert:id=\"star\">\\u2605</ns1:g>\n" + + "</string>" + + "</resources>") + .getBytes(StandardCharsets.UTF_8)); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("string/star_rating"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom( + ImmutableMap.of("ns1", "urn:oasis:names:tc:xliff:document:1.2"), + ImmutableMap.<String, String>of(), + source, + "<string name=\"star_rating\">Check out our 5 ", + "<ns1:g xmlns:foglebert=\"defogle\" " + "foglebert:id=\"star\">\\u2605</ns1:g>", + "</string>")); + } + + @Test + public void writeStringResourceWithUnusedNamespace() throws Exception { + Path source = fs.getPath("root/values/values.xml"); + Files.createDirectories(source.getParent()); + Files.write( + source, + ("<resources xmlns:ns1=\"urn:oasis:names:tc:xliff:document:1.2\">" + + "<string name=\"star_rating\">" + + "not yet implemented\n" + + "</string>" + + "</resources>") + .getBytes(StandardCharsets.UTF_8)); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("string/star_rating"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom( + source, "<string name=\"star_rating\">", "not yet implemented\n", "</string>")); + } + + @Test + public void writeStringResourceWithEscapedValues() throws Exception { + String[] xml = { + "<string name=\"AAP_SUGGEST_ACCEPT_SUGGESTION\">", + " <b><xliff:g id=\"name\" example=\"Pizza hut\">%1$s</xliff:g></b> " + + "already exists at <b><xliff:g id=\"address\" " + + "example=\"123 main street\">%2$s</xliff:g></b><br>", + " <br>", + " Is this the place you\\'re trying to add?\n", + "</string>" + }; + + Path source = writeResourceXml(XLIFF_NAMESPACES, ImmutableMap.<String, String>of(), xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("string/AAP_SUGGEST_ACCEPT_SUGGESTION"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom(XLIFF_NAMESPACES, ImmutableMap.<String, String>of(), source, xml)); + } + + @Test + public void writeStyleableXmlResource() throws Exception { + String[] xml = { + "<declare-styleable name='Theme'>", + " <attr name=\"labelPosition\" />", + "</declare-styleable>" + }; + + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("styleable/Theme"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writeStyleableXmlResourceReference() throws Exception { + String[] xml = { + "<declare-styleable name='Theme'>", + " <attr name=\"labelColor\" format=\"color\" />", + "</declare-styleable>" + }; + + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("styleable/Theme"), fqn("attr/labelColor"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writePluralXmlResources() throws Exception { + String[] xml = { + "<plurals name='numberOfSongsAvailable' format='none'>", + " <item quantity='one'>%d song found.</item>", + " <item quantity='other'>%d songs found.</item>", + "</plurals>" + }; + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("plurals/numberOfSongsAvailable"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writePublicXmlResources() throws Exception { + String[] xml = { + "<public name='bar' type='dimen' id='0x7f030003' />", + "<public name='foo' type='dimen' />", + }; + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("public/bar"), fqn("public/foo"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writeArrayXmlResources() throws Exception { + String[] xml = { + "<array name='icons'>", + " <item>@drawable/home</item>", + " <item>@drawable/settings</item>", + " <item>@drawable/logout</item>", + "</array>", + }; + + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("array/icons"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writeStringArrayXmlResources() throws Exception { + String[] xml = { + "<string-array name='rosebud' translatable='false'>", + " <item>Howard Hughes</item>", + " <item>Randolph Hurst</item>", + "</string-array>", + }; + + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("array/rosebud"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writeIntegerArrayXmlResources() throws Exception { + String[] xml = { + "<integer-array name='bits'>", + " <item>4</item>", + " <item>8</item>", + " <item>16</item>", + " <item>32</item>", + "</integer-array>" + }; + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("array/bits"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writeAttrFlagXmlResources() throws Exception { + String[] xml = { + " <attr name=\"labelPosition\">", + " <flag name=\"left\" value=\"0\"/>", + " <flag name=\"right\" value=\"1\"/>", + " </attr>" + }; + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("attr/labelPosition"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom( + source, + " <attr name=\"labelPosition\">", + " <flag name=\"left\" value=\"0\"/>", + " <flag name=\"right\" value=\"1\"/>", + " </attr>")); + } + + @Test + public void writeAttrMultiFormatImplicitFlagXmlResources() throws Exception { + String[] xml = { + " <attr name='labelPosition' format='reference'>", + " <flag name='left' value='0'/>", + " <flag name='right' value='1'/>", + " </attr>" + }; + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("attr/labelPosition"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom( + source, + " <attr name='labelPosition' format='reference'>", + " <flag name='left' value='0'/>", + " <flag name='right' value='1'/>", + " </attr>")); + } + + @Test + public void writeAttrMultiFormatResources() throws Exception { + String[] xml = { + "<attr name='labelPosition' ", + "format='boolean|color|dimension|float|fraction|integer|reference|string'/>" + }; + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("attr/labelPosition"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writeStyle() throws Exception { + String[] xml = { + "<style name='candlestick_maker' parent='@style/wax_maker'>\n" + + " <item name='texture'>waxy</item>\n" + + "</style>" + }; + + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("style/candlestick_maker"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writeForceNoParentStyle() throws Exception { + String[] xml = { + "<style name='candlestick_maker' parent=''>\n", + " <item name='texture'>waxy</item>\n", + "</style>" + }; + + Path source = writeResourceXml(xml); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("style/candlestick_maker"))) + .xmlContentsIsEqualTo(resourcesXmlFrom(source, xml)); + } + + @Test + public void writeResourceAttributes() throws Exception { + Path source = writeResourceXml( + ImmutableMap.of("tools", "http://schemas.android.com/tools"), + ImmutableMap.of("tools:foo", "fooVal")); + assertAbout(resourcePaths) + .that(parsedAndWritten(source, fqn("<resources>/tools:foo"))) + .xmlContentsIsEqualTo( + resourcesXmlFrom( + ImmutableMap.of("tools", "http://schemas.android.com/tools"), + ImmutableMap.of("tools:foo", "fooVal"), + null)); + } + + @Test + public void serializeMultipleSimpleXmlResources() throws Exception { + Path serialized = fs.getPath("out/out.bin"); + Path source = fs.getPath("res/values/values.xml"); + FullyQualifiedName stringKey = fqn("string/exit"); + DataResourceXml stringValue = + DataResourceXml.createWithNoNamespace( + source, + SimpleXmlResourceValue.createWithValue(SimpleXmlResourceValue.Type.STRING, "way out")); + FullyQualifiedName nullStringKey = fqn("string/nullexit"); + DataResourceXml nullStringValue = + DataResourceXml.createWithNoNamespace( + source, + SimpleXmlResourceValue.createWithValue(SimpleXmlResourceValue.Type.STRING, null)); + FullyQualifiedName boolKey = fqn("bool/canExit"); + DataResourceXml boolValue = + DataResourceXml.createWithNoNamespace( + source, + SimpleXmlResourceValue.createWithValue(SimpleXmlResourceValue.Type.BOOL, "false")); + FullyQualifiedName colorKey = fqn("color/exitColor"); + DataResourceXml colorValue = + DataResourceXml.createWithNoNamespace( + source, + SimpleXmlResourceValue.createWithValue(SimpleXmlResourceValue.Type.COLOR, "#FF000000")); + FullyQualifiedName dimenKey = fqn("dimen/exitSize"); + DataResourceXml dimenValue = + DataResourceXml.createWithNoNamespace( + source, + SimpleXmlResourceValue.createWithValue(SimpleXmlResourceValue.Type.DIMEN, "20sp")); + + AndroidDataSerializer serializer = AndroidDataSerializer.create(); + serializer.queueForSerialization(stringKey, stringValue); + serializer.queueForSerialization(nullStringKey, nullStringValue); + serializer.queueForSerialization(boolKey, boolValue); + serializer.queueForSerialization(colorKey, colorValue); + serializer.queueForSerialization(dimenKey, dimenValue); + serializer.flushTo(serialized); + + AndroidDataDeserializer deserializer = AndroidDataDeserializer.create(); + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + deserializer.read( + serialized, + KeyValueConsumers.of(new FakeConsumer(toOverwrite), new FakeConsumer(toCombine), null)); + assertThat(toOverwrite) + .containsExactly( + stringKey, stringValue, + boolKey, boolValue, + colorKey, colorValue, + nullStringKey, nullStringValue, + dimenKey, dimenValue); + assertThat(toCombine).isEmpty(); + } + + @Test + public void serializeItemXmlResources() throws Exception { + Path source = fs.getPath("res/values/values.xml"); + assertSerialization( + fqn("dimen/exitSizePercent"), + DataResourceXml.createWithNoNamespace( + source, SimpleXmlResourceValue.itemWithValue(ResourceType.DIMEN, "20%"))); + assertSerialization( + fqn("dimen/exitSizeFloat"), + DataResourceXml.createWithNoNamespace( + source, + SimpleXmlResourceValue.itemWithFormattedValue(ResourceType.DIMEN, "float", "20.0"))); + assertSerialization( + fqn("fraction/denom"), + DataResourceXml.createWithNoNamespace( + source, + SimpleXmlResourceValue.createWithValue(SimpleXmlResourceValue.Type.COLOR, "5%"))); + assertSerialization( + fqn("id/subtype_afrikaans"), + DataResourceXml.createWithNoNamespace( + source, SimpleXmlResourceValue.itemWithValue(ResourceType.ID, "0x6f972360"))); + } + + @Test + public void serializeStyleableXmlResource() throws Exception { + Path serialized = fs.getPath("out/out.bin"); + Path source = fs.getPath("res/values/values.xml"); + FullyQualifiedName attrKey = fqn("attr/labelPosition"); + DataResourceXml attrValue = + DataResourceXml.createWithNoNamespace( + source, + AttrXmlResourceValue.fromFormatEntries( + EnumResourceXmlAttrValue.asEntryOf("left", "0", "right", "1"))); + FullyQualifiedName themeKey = fqn("styleable/Theme"); + DataResourceXml themeValue = + DataResourceXml.createWithNoNamespace( + source, + StyleableXmlResourceValue.createAllAttrAsReferences( + fqnFactory.parse("attr/labelPosition"))); + + AndroidDataSerializer serializer = AndroidDataSerializer.create(); + serializer.queueForSerialization(attrKey, attrValue); + serializer.queueForSerialization(themeKey, themeValue); + serializer.flushTo(serialized); + + AndroidDataDeserializer deserializer = AndroidDataDeserializer.create(); + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + deserializer.read( + serialized, + KeyValueConsumers.of(new FakeConsumer(toOverwrite), new FakeConsumer(toCombine), null)); + assertThat(toOverwrite).containsEntry(attrKey, attrValue); + assertThat(toCombine).containsEntry(themeKey, themeValue); + } + + @Test + public void serializePlurals() throws Exception { + Path path = fs.getPath("res/values/values.xml"); + assertSerialization( + fqn("plurals/numberOfSongsAvailable"), + DataResourceXml.createWithNoNamespace( + path, + PluralXmlResourceValue.createWithoutAttributes( + ImmutableMap.of( + "one", "%d song found.", + "other", "%d songs found.")))); + } + + @Test + public void serializeArrays() throws Exception { + Path path = fs.getPath("res/values/values.xml"); + assertSerialization( + fqn("plurals/numberOfSongsAvailable"), + DataResourceXml.createWithNoNamespace( + path, + PluralXmlResourceValue.createWithoutAttributes( + ImmutableMap.of( + "one", "%d song found.", + "other", "%d songs found.")))); + assertSerialization( + fqn("array/icons"), + DataResourceXml.createWithNoNamespace( + path, + ArrayXmlResourceValue.of( + ArrayType.ARRAY, "@drawable/home", "@drawable/settings", "@drawable/logout"))); + assertSerialization( + fqn("array/colors"), + DataResourceXml.createWithNoNamespace( + path, + ArrayXmlResourceValue.of(ArrayType.ARRAY, "#FFFF0000", "#FF00FF00", "#FF0000FF"))); + assertSerialization( + fqn("array/characters"), + DataResourceXml.createWithNoNamespace( + path, + ArrayXmlResourceValue.of( + ArrayType.STRING_ARRAY, + "boojum", + "snark", + "bellman", + "barrister", + "\\\"billiard-marker\\\""))); + } + + @Test + public void serializeAttrFlag() throws Exception { + assertSerialization( + fqn("attr/labelPosition"), + DataResourceXml.createWithNoNamespace( + fs.getPath("res/values/values.xml"), + AttrXmlResourceValue.fromFormatEntries( + FlagResourceXmlAttrValue.asEntryOf( + "left", "0", + "right", "1")))); + } + + @Test + public void serializeId() throws Exception { + assertSerialization( + fqn("id/squark"), + DataResourceXml.createWithNoNamespace( + fs.getPath("res/values/values.xml"), IdXmlResourceValue.of())); + } + + @Test + public void serializePublic() throws Exception { + assertSerialization( + fqn("public/park"), + DataResourceXml.createWithNoNamespace( + fs.getPath("res/values/public.xml"), PublicXmlResourceValue.of( + ImmutableMap.of( + ResourceType.DIMEN, Optional.of(0x7f040000), + ResourceType.STRING, Optional.of(0x7f050000)) + ))); + } + + @Test + public void serializeStyle() throws Exception { + assertSerialization( + fqn("style/snark"), + DataResourceXml.createWithNoNamespace( + fs.getPath("res/values/styles.xml"), + StyleXmlResourceValue.of(null, ImmutableMap.of("look", "boojum")))); + } + + @Test + public void assertMultiFormatAttr() throws Exception { + assertSerialization( + fqn("attr/labelPosition"), + DataResourceXml.createWithNoNamespace( + fs.getPath("res/values/values.xml"), + AttrXmlResourceValue.fromFormatEntries( + ColorResourceXmlAttrValue.asEntry(), + BooleanResourceXmlAttrValue.asEntry(), + ReferenceResourceXmlAttrValue.asEntry(), + DimensionResourceXmlAttrValue.asEntry(), + FloatResourceXmlAttrValue.asEntry(), + IntegerResourceXmlAttrValue.asEntry(), + StringResourceXmlAttrValue.asEntry(), + FractionResourceXmlAttrValue.asEntry()))); + } + + private void assertSerialization(FullyQualifiedName key, DataValue value) throws Exception { + Path serialized = fs.getPath("out/out.bin"); + Files.deleteIfExists(serialized); + Path manifestPath = fs.getPath("AndroidManifest.xml"); + Files.deleteIfExists(manifestPath); + Files.createFile(manifestPath); + AndroidDataSerializer serializer = AndroidDataSerializer.create(); + serializer.queueForSerialization(key, value); + serializer.flushTo(serialized); + + AndroidDataDeserializer deserializer = AndroidDataDeserializer.create(); + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + deserializer.read( + serialized, + KeyValueConsumers.of(new FakeConsumer(toOverwrite), new FakeConsumer(toCombine), null)); + if (key.isOverwritable()) { + assertThat(toOverwrite).containsEntry(key, value); + assertThat(toCombine).isEmpty(); + } else { + assertThat(toCombine).containsEntry(key, value); + assertThat(toOverwrite).isEmpty(); + } + } + + private String[] resourcesXmlFrom(Path source, String... lines) { + return resourcesXmlFrom(ImmutableMap.<String, String>of(), ImmutableMap.<String, String>of(), + source, lines); + } + + private String[] resourcesXmlFrom(Map<String, String> namespaces, Map<String, String> attributes, + Path source, String... lines) { + FluentIterable<String> xml = FluentIterable.of(new String(AndroidDataWriter.PRELUDE)) + .append("<resources") + .append( + FluentIterable.from(namespaces.entrySet()) + .transform( + new Function<Entry<String, String>, String>() { + @Override + public String apply(Entry<String, String> input) { + return String.format(" xmlns:%s=\"%s\"", input.getKey(), input.getValue()); + } + }) + .join(Joiner.on(""))) + .append( + FluentIterable.from(attributes.entrySet()) + .transform( + new Function<Entry<String, String>, String>() { + @Override + public String apply(Entry<String, String> input) { + return String.format(" %s=\"%s\"", input.getKey(), input.getValue()); + } + }) + .join(Joiner.on(""))); + if (source == null && (lines == null || lines.length == 0)) { + xml = xml.append("/>"); + } else { + xml = xml.append(">"); + if (source != null) { + xml = xml.append(String.format("<!-- %s --> <eat-comment/>", source)); + } + if (lines != null) { + xml = xml.append(lines); + } + xml = xml.append(END_RESOURCES); + } + return xml.toArray(String.class); + } + + private Path parsedAndWritten(Path path, FullyQualifiedName... fqns) + throws XMLStreamException, IOException, MergingException { + final Map<DataKey, DataResource> toOverwrite = new HashMap<>(); + final Map<DataKey, DataResource> toCombine = new HashMap<>(); + parseResourcesFrom(path, toOverwrite, toCombine); + Path out = fs.getPath("out"); + if (Files.exists(out)) { + MoreFiles.deleteRecursively(out, RecursiveDeleteOption.ALLOW_INSECURE); + } + // find and write the resource -- the categorization is tested during parsing. + AndroidDataWriter mergedDataWriter = AndroidDataWriter.createWithDefaults(out); + for (FullyQualifiedName fqn : fqns) { + if (toOverwrite.containsKey(fqn)) { + toOverwrite.get(fqn).writeResource(fqn, mergedDataWriter); + } else if (toCombine.containsKey(fqn)) { + toCombine.get(fqn).writeResource(fqn, mergedDataWriter); + } + } + mergedDataWriter.flush(); + return mergedDataWriter.resourceDirectory().resolve("values/values.xml"); + } + + private static final SubjectFactory<PathsSubject, Path> resourcePaths = + new SubjectFactory<PathsSubject, Path>() { + @Override + public PathsSubject getSubject(FailureStrategy failureStrategy, Path path) { + return new PathsSubject(failureStrategy, path); + } + }; + + private static class FakeConsumer + implements ParsedAndroidData.KeyValueConsumer<DataKey, DataResource> { + private final Map<DataKey, DataResource> target; + + FakeConsumer(Map<DataKey, DataResource> target) { + this.target = target; + } + + @Override + public void consume(DataKey key, DataResource value) { + target.put(key, value); + } + } +} diff --git a/src/test/java/com/google/devtools/build/android/ParsedAndroidDataBuilder.java b/src/test/java/com/google/devtools/build/android/ParsedAndroidDataBuilder.java new file mode 100644 index 0000000000..19710f7e35 --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/ParsedAndroidDataBuilder.java @@ -0,0 +1,269 @@ +// 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; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.devtools.build.android.FullyQualifiedName.Factory; +import com.google.devtools.build.android.ParsedAndroidData.CombiningConsumer; +import com.google.devtools.build.android.ParsedAndroidData.KeyValueConsumer; +import com.google.devtools.build.android.ParsedAndroidData.OverwritableConsumer; +import com.google.devtools.build.android.xml.Namespaces; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import javax.annotation.Nullable; + +/** + * Build for ParsedAndroidData instance. + */ +public class ParsedAndroidDataBuilder { + + private final Path defaultRoot; + private final FullyQualifiedName.Factory fqnFactory; + private final Map<DataKey, DataResource> overwrite = new HashMap<>(); + private final Map<DataKey, DataResource> combine = new HashMap<>(); + private final Map<DataKey, DataAsset> assets = new HashMap<>(); + private final Set<MergeConflict> conflicts = new HashSet<>(); + + public ParsedAndroidDataBuilder( + @Nullable Path root, @Nullable FullyQualifiedName.Factory fqnFactory) { + this.defaultRoot = root; + this.fqnFactory = fqnFactory; + } + + public static ParsedAndroidData empty() { + return ParsedAndroidData.of( + ImmutableSet.<MergeConflict>of(), + ImmutableMap.<DataKey, DataResource>of(), + ImmutableMap.<DataKey, DataResource>of(), + ImmutableMap.<DataKey, DataAsset>of()); + } + + public static ParsedAndroidDataBuilder buildOn( + Path defaultRoot, FullyQualifiedName.Factory fqnFactory) { + return new ParsedAndroidDataBuilder(defaultRoot, fqnFactory); + } + + public static ParsedAndroidDataBuilder buildOn(FullyQualifiedName.Factory fqnFactory) { + return buildOn(null, fqnFactory); + } + + public static ParsedAndroidDataBuilder buildOn(Path defaultRoot) { + return buildOn(defaultRoot, null); + } + + public static ParsedAndroidDataBuilder builder() { + return buildOn(null, null); + } + + public ParsedAndroidDataBuilder overwritable(DataEntry... resourceBuilders) { + OverwritableConsumer<DataKey, DataResource> consumer = + new OverwritableConsumer<>(overwrite, conflicts); + for (DataEntry resourceBuilder : resourceBuilders) { + resourceBuilder.accept(fqnFactory, defaultRoot, consumer); + } + return this; + } + + public ParsedAndroidDataBuilder combining(DataEntry... resourceBuilders) { + CombiningConsumer consumer = new CombiningConsumer(combine); + for (DataEntry resourceBuilder : resourceBuilders) { + resourceBuilder.accept(fqnFactory, defaultRoot, consumer); + } + return this; + } + + public ParsedAndroidDataBuilder assets(DataEntry... assetBuilders) { + OverwritableConsumer<DataKey, DataAsset> consumer = + new OverwritableConsumer<>(assets, conflicts); + for (DataEntry assetBuilder : assetBuilders) { + assetBuilder.accept(defaultRoot, consumer); + } + return this; + } + + public static FileResourceBuilder file(String rawKey) { + return new FileResourceBuilder(rawKey); + } + + public static FileResourceBuilder file() { + return new FileResourceBuilder(null); + } + + public static XmlResourceBuilder xml(String rawKey) { + return new XmlResourceBuilder(rawKey); + } + + public ParsedAndroidData build() { + return ParsedAndroidData.of( + ImmutableSet.copyOf(conflicts), + ImmutableMap.copyOf(overwrite), + ImmutableMap.copyOf(combine), + ImmutableMap.copyOf(assets)); + } + + static class FileResourceBuilder { + private String rawKey; + private Path root; + + FileResourceBuilder(@Nullable String rawKey) { + this.rawKey = rawKey; + } + + FileResourceBuilder root(Path root) { + this.root = root; + return this; + } + + Path chooseRoot(Path defaultRoot) { + if (defaultRoot != null) { + return defaultRoot; + } + if (root != null) { + return root; + } + throw new IllegalStateException( + "the default root and asset root are null! A root is required!"); + } + + DataEntry source(final DataSource source) { + return new DataEntry() { + @Override + void accept( + @Nullable Factory factory, + @Nullable Path root, + KeyValueConsumer<DataKey, DataResource> consumer) { + consumer.consume(factory.parse(rawKey), DataValueFile.of(source)); + } + + @Override + void accept(@Nullable Path defaultRoot, KeyValueConsumer<DataKey, DataAsset> target) { + target.consume( + RelativeAssetPath.Factory.of(chooseRoot(defaultRoot).resolve("assets")) + .create(source.getPath()), + DataValueFile.of(source)); + } + }; + } + + DataEntry source(final String path) { + return new DataEntry() { + @Override + public void accept( + FullyQualifiedName.Factory factory, + Path defaultRoot, + KeyValueConsumer<DataKey, DataResource> consumer) { + Path res = chooseRoot(defaultRoot).resolve("res"); + consumer.consume(factory.parse(rawKey), DataValueFile.of(res.resolve(path))); + } + + @Override + public void accept( + @Nullable Path defaultRoot, KeyValueConsumer<DataKey, DataAsset> consumer) { + Path assets = chooseRoot(defaultRoot).resolve("assets"); + Path fullPath = assets.resolve(path); + consumer.consume( + RelativeAssetPath.Factory.of(assets).create(fullPath), DataValueFile.of(fullPath)); + } + }; + } + } + + static class XmlResourceBuilder { + private final String rawFqn; + private Path root; + private final Map<String, String> prefixToUri = new LinkedHashMap<>(); + + XmlResourceBuilder(String rawFqn) { + this(rawFqn, null); + } + + XmlResourceBuilder(String rawFqn, Path root) { + this.rawFqn = rawFqn; + this.root = root; + } + + XmlResourceBuilder source(final String path) { + return new XmlResourceBuilder(rawFqn, root) { + @Override + public DataEntry value(final XmlResourceValue value) { + return new DataEntry() { + @Override + public void accept( + FullyQualifiedName.Factory factory, + Path defaultRoot, + KeyValueConsumer<DataKey, DataResource> consumer) { + Path res = (root == null ? defaultRoot : root).resolve("res"); + consumer.consume( + factory.parse(rawFqn), + DataResourceXml.createWithNamespaces( + res.resolve(path), value, Namespaces.from(prefixToUri))); + } + }; + } + }; + } + + XmlResourceBuilder source(final DataSource dataSource) { + return new XmlResourceBuilder(rawFqn, root) { + @Override + public DataEntry value(final XmlResourceValue value) { + return new DataEntry() { + @Override + public void accept( + FullyQualifiedName.Factory factory, + Path defaultRoot, + KeyValueConsumer<DataKey, DataResource> consumer) { + consumer.consume( + factory.parse(rawFqn), + DataResourceXml.createWithNamespaces( + dataSource, value, Namespaces.from(prefixToUri))); + } + }; + } + }; + } + + XmlResourceBuilder root(Path root) { + this.root = root; + return this; + } + + XmlResourceBuilder namespace(String prefix, String uri) { + prefixToUri.put(prefix, uri); + return this; + } + + DataEntry value(final XmlResourceValue value) { + throw new UnsupportedOperationException("A source must be declared!"); + } + } + + abstract static class DataEntry { + void accept( + @Nullable FullyQualifiedName.Factory factory, + @Nullable Path root, + KeyValueConsumer<DataKey, DataResource> consumer) { + throw new UnsupportedOperationException("assets cannot be resources!"); + } + + void accept(@Nullable Path root, KeyValueConsumer<DataKey, DataAsset> target) { + throw new UnsupportedOperationException("xml resources cannot be assets!"); + } + } +} diff --git a/src/test/java/com/google/devtools/build/android/PathsSubject.java b/src/test/java/com/google/devtools/build/android/PathsSubject.java new file mode 100644 index 0000000000..54ffec371f --- /dev/null +++ b/src/test/java/com/google/devtools/build/android/PathsSubject.java @@ -0,0 +1,93 @@ +// 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; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.base.Joiner; +import com.google.common.truth.FailureStrategy; +import com.google.common.truth.Subject; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import javax.annotation.Nullable; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.w3c.dom.Document; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +/** + * A testing utility that allows assertions against Paths. + */ +class PathsSubject extends Subject<PathsSubject, Path> { + + PathsSubject(FailureStrategy failureStrategy, @Nullable Path subject) { + super(failureStrategy, subject); + } + + void exists() { + if (getSubject() == null) { + fail("should not be null."); + } + if (!Files.exists(getSubject())) { + fail("exists."); + } + } + + void xmlContentsIsEqualTo(String... contents) { + if (getSubject() == null) { + fail("should not be null."); + } + exists(); + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + transformer.setOutputProperty(OutputKeys.INDENT, "no"); + + assertThat( + normalizeXml( + newXmlDocument(factory, Files.readAllLines(getSubject(), StandardCharsets.UTF_8)), + transformer)) + .isEqualTo(normalizeXml(newXmlDocument(factory, Arrays.asList(contents)), transformer)); + } catch (IOException | SAXException | ParserConfigurationException | TransformerException e) { + fail(e.toString()); + } + } + + private Document newXmlDocument(DocumentBuilderFactory factory, List<String> contents) + throws SAXException, IOException, ParserConfigurationException { + return factory + .newDocumentBuilder() + .parse(new InputSource(new StringReader(Joiner.on("").join(contents)))); + } + + private String normalizeXml(Document doc, Transformer transformer) throws TransformerException { + StringWriter writer = new StringWriter(); + transformer.transform(new DOMSource(doc), new StreamResult(writer)); + return writer.toString().replaceAll("\n|\r", "").replaceAll(">\\s+<", "><"); + } +} |