| // Copyright 2016 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.annotations.VisibleForTesting; |
| import com.google.common.base.Joiner; |
| import com.google.common.base.Preconditions; |
| import com.google.common.collect.ArrayListMultimap; |
| import com.google.common.collect.Maps; |
| import com.google.common.collect.Multimap; |
| import com.google.common.collect.Ordering; |
| import com.google.common.util.concurrent.ListenableFuture; |
| import com.google.common.util.concurrent.ListeningExecutorService; |
| import com.google.common.util.concurrent.MoreExecutors; |
| import com.google.devtools.build.android.xml.Namespaces; |
| import com.google.errorprone.annotations.CanIgnoreReturnValue; |
| import java.io.BufferedWriter; |
| import java.io.IOException; |
| import java.io.Writer; |
| import java.nio.charset.StandardCharsets; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.nio.file.StandardCopyOption; |
| import java.nio.file.StandardOpenOption; |
| import java.util.ArrayDeque; |
| import java.util.ArrayList; |
| import java.util.Deque; |
| import java.util.Iterator; |
| import java.util.LinkedHashMap; |
| import java.util.LinkedHashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.concurrent.Callable; |
| import java.util.concurrent.ExecutorService; |
| import javax.annotation.Nullable; |
| import javax.xml.namespace.QName; |
| |
| /** Writer for UnwrittenMergedAndroidData. */ |
| public class AndroidDataWriter implements AndroidDataWritingVisitor { |
| |
| private static final class CopyTask implements Callable<Boolean> { |
| |
| private final Path sourcePath; |
| |
| private final Path destinationPath; |
| |
| private CopyTask(Path sourcePath, Path destinationPath) { |
| this.sourcePath = sourcePath; |
| this.destinationPath = destinationPath; |
| } |
| |
| @Override |
| public Boolean call() throws Exception { |
| Files.createDirectories(destinationPath.getParent()); |
| Files.copy(sourcePath, destinationPath, StandardCopyOption.REPLACE_EXISTING); |
| return Boolean.TRUE; |
| } |
| } |
| |
| public static final char[] PRELUDE = |
| "<?xml version=\"1.0\" encoding='utf-8' standalone='no'?>\n".toCharArray(); |
| private static final char[] START_RESOURCES_TAG = "<resources".toCharArray(); |
| public static final char[] END_RESOURCES = "</resources>".toCharArray(); |
| private static final char[] LINE_END = "\n".toCharArray(); |
| |
| private final Path destination; |
| |
| private final Map<String, ResourceValuesDefinitions> valueTags = new LinkedHashMap<>(); |
| private final Path resourceDirectory; |
| private final Path assetDirectory; |
| private final List<ListenableFuture<Boolean>> writeTasks = new ArrayList<>(); |
| private final ListeningExecutorService executorService; |
| |
| private AndroidDataWriter( |
| Path destination, |
| Path resourceDirectory, |
| Path assetsDirectory, |
| ListeningExecutorService executorService) { |
| this.destination = destination; |
| this.resourceDirectory = resourceDirectory; |
| this.assetDirectory = assetsDirectory; |
| this.executorService = executorService; |
| } |
| |
| /** |
| * Creates a new, naive writer for testing. |
| * |
| * <p>This writer has "assets" and a "res" directory from the destination directory, as well as a |
| * {@link ExecutorService} of 1 thread. |
| * |
| * @param destination The base directory to derive all paths. |
| * @return A new {@link AndroidDataWriter}. |
| */ |
| @VisibleForTesting |
| static AndroidDataWriter createWithDefaults(Path destination) { |
| return createWith( |
| destination, |
| destination.resolve("res"), |
| destination.resolve("assets"), |
| MoreExecutors.newDirectExecutorService()); |
| } |
| |
| /** |
| * Creates a new writer. |
| * |
| * @param manifestDirectory The base directory for the AndroidManifest. |
| * @param resourceDirectory The directory to copy resources into. |
| * @param assetsDirectory The directory to copy assets into. |
| * @param executorService An execution service for multi-threaded writing. |
| * @return A new {@link AndroidDataWriter}. |
| */ |
| public static AndroidDataWriter createWith( |
| Path manifestDirectory, |
| Path resourceDirectory, |
| Path assetsDirectory, |
| ListeningExecutorService executorService) { |
| return new AndroidDataWriter( |
| manifestDirectory, |
| resourceDirectory, |
| assetsDirectory, |
| executorService); |
| } |
| |
| @Override |
| public Path copyManifest(Path sourceManifest) throws IOException { |
| // aapt won't read any manifest that is not named AndroidManifest.xml, |
| // so we hard code it here. |
| Path destinationManifest = destination.resolve("AndroidManifest.xml"); |
| copy(sourceManifest, destinationManifest); |
| return destinationManifest; |
| } |
| |
| public Path assetDirectory() { |
| return assetDirectory; |
| } |
| |
| public Path resourceDirectory() { |
| return resourceDirectory; |
| } |
| |
| @Override |
| public void copyAsset(Path source, String relativeDestinationPath) throws IOException { |
| copy(source, assetDirectory.resolve(relativeDestinationPath)); |
| } |
| |
| @Override |
| public void copyResource(final Path source, final String relativeDestinationPath) { |
| final Path destinationPath = resourceDirectory.resolve(relativeDestinationPath); |
| copy(source, destinationPath); |
| } |
| |
| private void copy(final Path sourcePath, final Path destinationPath) { |
| writeTasks.add(executorService.submit(new CopyTask(sourcePath, destinationPath))); |
| } |
| |
| /** Finalizes all operations and flushes the buffers. */ |
| @Override |
| public void flush() throws IOException { |
| for (Map.Entry<String, ResourceValuesDefinitions> entry : valueTags.entrySet()) { |
| writeTasks.add( |
| executorService.submit( |
| entry.getValue().createWritingTask(resourceDirectory().resolve(entry.getKey())))); |
| } |
| |
| FailedFutureAggregator.forIOExceptionsWithMessage("Failures during writing.") |
| .aggregateAndMaybeThrow(writeTasks); |
| |
| writeTasks.clear(); |
| } |
| |
| @Override |
| public ValueResourceDefinitionMetadata define(FullyQualifiedName fqn) { |
| String valuesPath = fqn.valuesPath(); |
| if (!valueTags.containsKey(valuesPath)) { |
| valueTags.put(valuesPath, new ResourceValuesDefinitions()); |
| } |
| return valueTags.get(valuesPath).resource(fqn); |
| } |
| |
| @Override |
| public void defineAttribute(FullyQualifiedName fqn, String name, String value) { |
| String valuesPath = fqn.valuesPath(); |
| if (!valueTags.containsKey(valuesPath)) { |
| valueTags.put(valuesPath, new ResourceValuesDefinitions()); |
| } |
| valueTags.get(valuesPath).addAttribute(name, value); |
| } |
| |
| @Override |
| public void defineNamespacesFor(FullyQualifiedName fqn, Namespaces namespaces) { |
| String valuesPath = fqn.valuesPath(); |
| if (!valueTags.containsKey(valuesPath)) { |
| valueTags.put(valuesPath, new ResourceValuesDefinitions()); |
| } |
| valueTags.get(valuesPath).addAllNamespaces(namespaces); |
| } |
| |
| /** A container for the {@linkplain Segment}s of a values.xml file. */ |
| private static class ResourceValuesDefinitions { |
| private static final class WritingTask implements Callable<Boolean> { |
| |
| private final Path valuesPath; |
| |
| private final Multimap<FullyQualifiedName, Segment> segments; |
| private final Set<FullyQualifiedName> adopted; |
| private final Namespaces namespaces; |
| private final Map<String, String> attributes; |
| |
| private WritingTask( |
| Path valuesPath, |
| Namespaces namespaces, |
| Map<String, String> attributes, |
| Set<FullyQualifiedName> adopted, |
| Multimap<FullyQualifiedName, Segment> segments) { |
| this.valuesPath = valuesPath; |
| this.namespaces = namespaces; |
| this.attributes = attributes; |
| this.adopted = adopted; |
| this.segments = segments; |
| } |
| |
| @Override |
| public Boolean call() throws Exception { |
| Files.createDirectories(valuesPath.getParent()); |
| try (BufferedWriter writer = |
| Files.newBufferedWriter( |
| valuesPath, |
| StandardCharsets.UTF_8, |
| StandardOpenOption.CREATE_NEW, |
| StandardOpenOption.WRITE)) { |
| writer.write(PRELUDE); |
| writer.write(START_RESOURCES_TAG); |
| for (Map.Entry<String, String> prefixToUri : namespaces) { |
| writer.write(" xmlns:"); |
| writer.write(prefixToUri.getKey()); |
| writer.write("=\""); |
| writer.write(prefixToUri.getValue()); |
| writer.write("\""); |
| } |
| for (Map.Entry<String, String> attribute : attributes.entrySet()) { |
| writer.write(" "); |
| writer.write(attribute.getKey()); |
| writer.write("=\""); |
| writer.write(attribute.getValue()); |
| writer.write("\""); |
| } |
| writer.write(">"); |
| writer.write(LINE_END); |
| Path previousSource = null; |
| for (FullyQualifiedName key : Ordering.natural().immutableSortedCopy(segments.keySet())) { |
| if (!adopted.contains(key)) { |
| for (Segment segment : segments.get(key)) { |
| previousSource = segment.write(previousSource, writer); |
| } |
| } |
| } |
| writer.write(END_RESOURCES); |
| } |
| return Boolean.TRUE; |
| } |
| } |
| |
| final Multimap<FullyQualifiedName, Segment> segments = ArrayListMultimap.create(); |
| final Set<FullyQualifiedName> adopted = new LinkedHashSet<>(); |
| Namespaces namespaces = Namespaces.empty(); |
| final Map<String, String> attributes = Maps.newLinkedHashMap(); |
| |
| private ValueResourceDefinitionMetadata resource(final FullyQualifiedName fqn) { |
| return new StringValueResourceDefinitionMetadata(segments, adopted, fqn); |
| } |
| |
| public void addAttribute(String name, String value) { |
| this.attributes.put(name, value); |
| } |
| |
| public void addAllNamespaces(Namespaces namespaces) { |
| this.namespaces = namespaces.union(this.namespaces); |
| } |
| |
| /** Generates a {@link Callable} that will write the {@link Segment} to the provided path. */ |
| public Callable<Boolean> createWritingTask(final Path valuesPath) { |
| return new WritingTask(valuesPath, namespaces, attributes, adopted, segments); |
| } |
| } |
| |
| /** Utility function to provide xml namespaced name if a prefix is defined. */ |
| private static String maybePrefixName(@Nullable String prefix, String name) { |
| if (prefix == null) { |
| return name; |
| } |
| return prefix + ":" + name; |
| } |
| |
| /** Intermediate class that associates a {@link Path} source with an xml definition. */ |
| private static class StringValueResourceDefinitionMetadata |
| implements ValueResourceDefinitionMetadata { |
| |
| private final Multimap<FullyQualifiedName, Segment> segments; |
| private final Set<FullyQualifiedName> adopted; |
| private final FullyQualifiedName segmentsKey; |
| |
| public StringValueResourceDefinitionMetadata( |
| Multimap<FullyQualifiedName, Segment> segments, |
| Set<FullyQualifiedName> adopted, |
| FullyQualifiedName fqn) { |
| this.segments = segments; |
| this.adopted = adopted; |
| this.segmentsKey = fqn; |
| } |
| |
| @Override |
| public ValuesResourceDefinition derivedFrom(final DataSource source) { |
| final SegmentMapper mapper = |
| SegmentMapper.create(segments, adopted, segmentsKey, source.getPath()); |
| return new StringValuesResourceDefinition(mapper); |
| } |
| } |
| |
| /** Intermediate class that builds a string attribute for a {@link StartTag} */ |
| private static final class StringAttribute implements Attribute { |
| |
| private final String name; |
| private final StringStartTag owner; |
| private final boolean optional; |
| |
| public StringAttribute(StringStartTag owner, String name, boolean optional) { |
| this.owner = owner; |
| this.name = name; |
| this.optional = optional; |
| } |
| |
| @Override |
| public StartTag setTo(FullyQualifiedName fqn) { |
| return setTo(fqn.name()); |
| } |
| |
| @Override |
| public StartTag setTo(String value) { |
| if (!optional || value != null) { |
| owner.attributes.add(" " + name + "=\"" + value + "\""); |
| } |
| return owner; |
| } |
| |
| @Override |
| public ValueJoiner setFrom(final Iterable<String> values) { |
| return new ValueJoiner() { |
| @Override |
| public StartTag joinedBy(String separator) { |
| Iterator<String> valuesIterator = values.iterator(); |
| if (!optional || valuesIterator.hasNext()) { |
| setTo(Joiner.on(separator).join(valuesIterator)); |
| } |
| return owner; |
| } |
| }; |
| } |
| } |
| |
| /** Intermediate class that collects information for writing an xml start tag string. */ |
| private static final class StringStartTag implements StartTag { |
| private final StringValuesResourceDefinition writer; |
| private final String tagName; |
| private final List<String> attributes = new ArrayList<>(); |
| |
| public StringStartTag(StringValuesResourceDefinition writer, String tagName) { |
| this.writer = writer; |
| this.tagName = tagName; |
| } |
| |
| @Override |
| public Attribute attribute(String prefix, String name) { |
| return createAttribute(prefix, name, false); |
| } |
| |
| @Override |
| public Attribute attribute(String name) { |
| return attribute(null, name); |
| } |
| |
| @Override |
| public StringValuesResourceDefinition closeTag() { |
| // Make sure we close this later. |
| writer.tagStack.push(tagName); |
| writer.mapper.add("<" + tagName + Joiner.on("").join(attributes) + ">"); |
| return writer; |
| } |
| |
| private Attribute createAttribute(String prefix, String name, boolean optional) { |
| return new StringAttribute(this, maybePrefixName(prefix, name), optional); |
| } |
| |
| @Override |
| public Optional optional() { |
| return new Optional() { |
| @Override |
| public Attribute attribute(String prefix, String name) { |
| return createAttribute(prefix, name, true); |
| } |
| |
| @Override |
| public Attribute attribute(String name) { |
| return createAttribute(null, name, true); |
| } |
| }; |
| } |
| |
| @Override |
| public ValuesResourceDefinition closeUnaryTag() { |
| writer.mapper.add("\n<" + tagName + Joiner.on("").join(attributes) + "/>"); |
| return writer; |
| } |
| |
| @Override |
| public StartTag named(FullyQualifiedName key) { |
| return named(key.name()); |
| } |
| |
| @Override |
| public StartTag named(String name) { |
| return createAttribute(null, "name", false).setTo(name); |
| } |
| |
| @Override |
| public StartTag addAttributesFrom(Iterable<Map.Entry<String, String>> entries) { |
| StartTag tag = this; |
| for (Map.Entry<String, String> entry : entries) { |
| tag = tag.attribute(entry.getKey()).setTo(entry.getValue()); |
| } |
| return tag; |
| } |
| } |
| |
| /** Intermediate class that provides methods to generate string xml definitions. */ |
| static class StringValuesResourceDefinition implements ValuesResourceDefinition { |
| private final SegmentMapper mapper; |
| private final Deque<String> tagStack = new ArrayDeque<>(); |
| |
| public StringValuesResourceDefinition(SegmentMapper mapper) { |
| this.mapper = mapper; |
| } |
| |
| @Override |
| public StartTag startTag(String prefix, String localName) { |
| final String tagName = maybePrefixName(prefix, localName); |
| return new StringStartTag(this, tagName); |
| } |
| |
| @Override |
| public StartTag startTag(String localName) { |
| return startTag(null, localName); |
| } |
| |
| @Override |
| public StartTag startTag(QName name) { |
| return startTag(name.getPrefix().isEmpty() ? null : name.getPrefix(), name.getLocalPart()); |
| } |
| |
| @CanIgnoreReturnValue |
| @Override |
| public ValuesResourceDefinition adopt(FullyQualifiedName fqn) { |
| mapper.adopt(fqn); |
| return this; |
| } |
| |
| @CanIgnoreReturnValue |
| @Override |
| public ValuesResourceDefinition addCharactersOf(String characters) { |
| mapper.add(characters); |
| return this; |
| } |
| |
| @CanIgnoreReturnValue |
| @Override |
| public ValuesResourceDefinition endTag() { |
| Preconditions.checkArgument( |
| !tagStack.isEmpty(), "Unable to endTag, as no tag has been started."); |
| mapper.add("</" + tagStack.pop() + ">"); |
| return this; |
| } |
| |
| @Override |
| public void save() { |
| Preconditions.checkArgument(tagStack.isEmpty(), "Unfinished tags %s", tagStack); |
| mapper.add("\n"); // Safe to add a line break to separate from other definitions. |
| mapper.finish(); |
| } |
| |
| @Override |
| public StartTag startItemTag() { |
| return startTag("item"); |
| } |
| } |
| |
| /** Maps {@link Segment}s to a {@link Multimap} via a {@link FullyQualifiedName} key. */ |
| private static class SegmentMapper { |
| private List<String> currentLines = new ArrayList<>(); |
| |
| private final Multimap<FullyQualifiedName, Segment> segmentStore; |
| final FullyQualifiedName segmentKey; |
| |
| private final Set<FullyQualifiedName> adopted; |
| |
| private SegmentMapper( |
| Set<FullyQualifiedName> adopted, |
| Multimap<FullyQualifiedName, Segment> segmentStore, |
| FullyQualifiedName segmentKey) { |
| this.adopted = adopted; |
| this.segmentStore = segmentStore; |
| this.segmentKey = segmentKey; |
| } |
| |
| static SegmentMapper create( |
| Multimap<FullyQualifiedName, Segment> segmentStore, |
| Set<FullyQualifiedName> adopted, |
| FullyQualifiedName segmentKey, |
| Path source) { |
| Preconditions.checkNotNull(source); |
| segmentStore.put(segmentKey, new SourceSegment(source)); |
| return new SegmentMapper(adopted, segmentStore, segmentKey); |
| } |
| |
| /** Adds a string to the current {@link StringsSegment}. */ |
| @CanIgnoreReturnValue |
| SegmentMapper add(String line) { |
| currentLines.add(line); |
| return this; |
| } |
| |
| /** Closes current {@link StringsSegment} and adds a {@link ReferenceSegment} to the store. */ |
| @CanIgnoreReturnValue |
| SegmentMapper adopt(FullyQualifiedName adoptedKey) { |
| segmentStore.put(segmentKey, new StringsSegment(currentLines)); |
| adopted.add(adoptedKey); |
| segmentStore.put(segmentKey, new ReferenceSegment(adoptedKey, segmentStore)); |
| currentLines = new ArrayList<>(); |
| return this; |
| } |
| |
| /** Ends the mapping for the current key. */ |
| void finish() { |
| segmentStore.put(segmentKey, new StringsSegment(currentLines)); |
| } |
| } |
| |
| /** Base interface for writing information to a {@link Writer}. */ |
| private static interface Segment { |
| /** |
| * Writes the segment contents to a Writer |
| * |
| * @param previousSource The source Path of the last Segment to be written. This can be used to |
| * omit source annotations from the Writer is the last source matches the current one. |
| * @param writer The writer of the segments. |
| * @return The source Path of this segment for the next segment. |
| * @throws IOException thrown by the writer. |
| */ |
| Path write(Path previousSource, @Nullable Writer writer) throws IOException; |
| } |
| |
| /** Represents a reference to another list of {@link Segment}s from the {@link Multimap}. */ |
| private static class ReferenceSegment implements Segment { |
| private final FullyQualifiedName fqn; |
| private final Multimap<FullyQualifiedName, Segment> segmentsByName; |
| |
| public ReferenceSegment( |
| FullyQualifiedName fqn, Multimap<FullyQualifiedName, Segment> segmentsByName) { |
| this.fqn = fqn; |
| this.segmentsByName = segmentsByName; |
| } |
| |
| @Override |
| public Path write(Path previousSource, Writer writer) throws IOException { |
| Path source = previousSource; |
| Preconditions.checkArgument( |
| segmentsByName.containsKey(fqn), |
| "%s has no segment in %s", |
| fqn.toPrettyString(), |
| segmentsByName.keySet()); |
| for (Segment s : segmentsByName.get(fqn)) { |
| // not recording the source |
| source = s.write(source, writer); |
| } |
| return source; |
| } |
| } |
| |
| /** A simple container for a list of strings to be written. */ |
| private static class StringsSegment implements Segment { |
| private final List<String> lines; |
| |
| public StringsSegment(List<String> lines) { |
| this.lines = lines; |
| } |
| |
| @Override |
| public Path write(Path previousSource, Writer writer) throws IOException { |
| for (String line : lines) { |
| writer.write(line); |
| } |
| return previousSource; |
| } |
| } |
| |
| /** Represents a resource source annotation to be written as xml. */ |
| private static class SourceSegment implements Segment { |
| |
| private final Path source; |
| |
| SourceSegment(Path source) { |
| this.source = source; |
| } |
| |
| @Override |
| public Path write(Path previousSource, Writer writer) throws IOException { |
| // If sources are equal don't write a new source annotation. |
| if (source.equals(previousSource)) { |
| return previousSource; |
| } |
| writer.write(String.format("<!-- %s -->", source)); |
| writer.write(LINE_END); |
| writer.write("<eat-comment/>"); |
| writer.write(LINE_END); |
| return source; |
| } |
| } |
| } |