/* * Copyright 2014-present Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. You may obtain * a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ package com.facebook.buck.android.aapt; import com.facebook.buck.android.AaptStep; import com.facebook.buck.android.aapt.RDotTxtEntry.IdType; import com.facebook.buck.android.aapt.RDotTxtEntry.RType; import com.facebook.buck.event.BuckEventBus; import com.facebook.buck.event.ConsoleEvent; import com.facebook.buck.io.ProjectFilesystem; import com.facebook.buck.rules.SourcePath; import com.facebook.buck.rules.SourcePathResolver; import com.facebook.buck.step.ExecutionContext; import com.facebook.buck.step.Step; import com.facebook.buck.step.StepExecutionResult; import com.facebook.buck.util.ThrowingPrintWriter; import com.facebook.buck.util.XmlDomParser; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.FluentIterable; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedSet; import com.google.common.collect.Ordering; import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; import java.util.Collection; import java.util.Optional; import java.util.Set; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpression; import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; /** * Step which parses resources in an android {@code res} directory and compiles them into a {@code * R.txt} file, following the exact same format as the Android build tool {@code aapt}. * * <p> */ public class MiniAapt implements Step { /** See {@link com.facebook.buck.android.AaptStep} for a list of files that we ignore. */ public static final ImmutableList<String> IGNORED_FILE_EXTENSIONS = ImmutableList.of("orig"); private static final String ID_DEFINITION_PREFIX = "@+id/"; private static final String ITEM_TAG = "item"; private static final String CUSTOM_DRAWABLE_PREFIX = "app-"; private static final XPathExpression ANDROID_ID_USAGE = createExpression( "//@*[starts-with(., '@') and " + "not(starts-with(., '@+')) and " + "not(starts-with(., '@android:')) and " + "not(starts-with(., '@null'))]"); private static final XPathExpression ANDROID_ID_DEFINITION = createExpression("//@*[starts-with(., '@+') and " + "not(starts-with(., '@+android:id'))]"); private static final ImmutableMap<String, RType> RESOURCE_TYPES = getResourceTypes(); private static final ImmutableSet<String> IGNORED_TAGS = ImmutableSet.of("eat-comment", "skip"); private final SourcePathResolver resolver; private final ProjectFilesystem filesystem; private final SourcePath resDirectory; private final Path pathToTextSymbolsFile; private final ImmutableSet<Path> pathsToSymbolsOfDeps; private final AaptResourceCollector resourceCollector; private final boolean resourceUnion; private final boolean isGrayscaleImageProcessingEnabled; public MiniAapt( SourcePathResolver resolver, ProjectFilesystem filesystem, SourcePath resDirectory, Path pathToTextSymbolsFile, ImmutableSet<Path> pathsToSymbolsOfDeps) { this( resolver, filesystem, resDirectory, pathToTextSymbolsFile, pathsToSymbolsOfDeps, /* resourceUnion */ false, /* isGrayscaleImageProcessingEnabled */ false); } public MiniAapt( SourcePathResolver resolver, ProjectFilesystem filesystem, SourcePath resDirectory, Path pathToTextSymbolsFile, ImmutableSet<Path> pathsToSymbolsOfDeps, boolean resourceUnion, boolean isGrayscaleImageProcessingEnabled) { this.resolver = resolver; this.filesystem = filesystem; this.resDirectory = resDirectory; this.pathToTextSymbolsFile = pathToTextSymbolsFile; this.pathsToSymbolsOfDeps = pathsToSymbolsOfDeps; this.resourceCollector = new AaptResourceCollector(); this.resourceUnion = resourceUnion; this.isGrayscaleImageProcessingEnabled = isGrayscaleImageProcessingEnabled; } private static XPathExpression createExpression(String expressionStr) { try { return XPathFactory.newInstance().newXPath().compile(expressionStr); } catch (XPathExpressionException e) { throw new RuntimeException(e); } } private static ImmutableMap<String, RType> getResourceTypes() { ImmutableMap.Builder<String, RType> types = ImmutableMap.builder(); for (RType rType : RType.values()) { types.put(rType.toString(), rType); } types.put("string-array", RType.ARRAY); types.put("integer-array", RType.ARRAY); types.put("declare-styleable", RType.STYLEABLE); return types.build(); } @VisibleForTesting AaptResourceCollector getResourceCollector() { return resourceCollector; } @Override public StepExecutionResult execute(ExecutionContext context) throws InterruptedException { ImmutableSet.Builder<RDotTxtEntry> references = ImmutableSet.builder(); try { collectResources(filesystem, context.getBuckEventBus()); processXmlFilesForIds(filesystem, references); } catch (IOException | XPathExpressionException | ResourceParseException e) { context.logError(e, "Error parsing resources to generate resource IDs for %s.", resDirectory); return StepExecutionResult.ERROR; } try { Set<RDotTxtEntry> missing = verifyReferences(filesystem, references.build()); if (!missing.isEmpty()) { context .getBuckEventBus() .post( ConsoleEvent.severe( "The following resources were not found when processing %s: \n%s\n", resDirectory, Joiner.on('\n').join(missing))); return StepExecutionResult.ERROR; } } catch (IOException e) { context.logError(e, "Error verifying resources for %s.", resDirectory); return StepExecutionResult.ERROR; } if (resourceUnion) { try { resourceUnion(); } catch (IOException e) { context.logError(e, "Error performing resource union for %s.", resDirectory); return StepExecutionResult.ERROR; } } try (ThrowingPrintWriter writer = new ThrowingPrintWriter(filesystem.newFileOutputStream(pathToTextSymbolsFile))) { Set<RDotTxtEntry> sortedResources = ImmutableSortedSet.copyOf(Ordering.natural(), resourceCollector.getResources()); for (RDotTxtEntry entry : sortedResources) { writer.printf("%s %s %s %s\n", entry.idType, entry.type, entry.name, entry.idValue); } } catch (IOException e) { context.logError(e, "Error writing file: %s", pathToTextSymbolsFile); return StepExecutionResult.ERROR; } return StepExecutionResult.SUCCESS; } /** * Collect resource information from R.txt for each dep and perform a resource union. * * @throws IOException */ public void resourceUnion() throws IOException { for (Path depRTxt : pathsToSymbolsOfDeps) { Iterable<String> lines = FluentIterable.from(filesystem.readLines(depRTxt)) .filter(input -> !Strings.isNullOrEmpty(input)) .toList(); for (String line : lines) { Optional<RDotTxtEntry> entry = RDotTxtEntry.parse(line); Preconditions.checkState(entry.isPresent()); resourceCollector.addResourceIfNotPresent(entry.get()); } } } /** * Collects file names under the {@code res} directory, except those under directories starting * with {@code values}, as resources based on their parent directory. * * <p>So for instance, if the directory structure is something like: * * <pre> * res/ * values/ ... * values-es/ ... * drawable/ * image.png * nine_patch.9.png * layout/ * my_view.xml * another_view.xml * </pre> * * the resulting resources would contain: * * <ul> * <li>R.drawable.image * <li>R.drawable.nine_patch * <li>R.layout.my_view * <li>R.layout.another_view * </ul> * * <p>For files under the {@code values*} directories, see {@link * #processValuesFile(ProjectFilesystem, Path)} */ private void collectResources(ProjectFilesystem filesystem, BuckEventBus eventBus) throws IOException, ResourceParseException { Collection<Path> contents = filesystem.getDirectoryContents(resolver.getAbsolutePath(resDirectory)); for (Path dir : contents) { if (!filesystem.isDirectory(dir) && !filesystem.isIgnored(dir)) { if (!shouldIgnoreFile(dir, filesystem)) { eventBus.post(ConsoleEvent.warning("MiniAapt [warning]: ignoring file '%s'.", dir)); } continue; } String dirname = dir.getFileName().toString(); if (dirname.startsWith("values")) { if (!isAValuesDir(dirname)) { throw new ResourceParseException("'%s' is not a valid values directory.", dir); } processValues(filesystem, eventBus, dir); } else { processFileNamesInDirectory(filesystem, dir); } } } void processFileNamesInDirectory(ProjectFilesystem filesystem, Path dir) throws IOException, ResourceParseException { String dirname = dir.getFileName().toString(); int dashIndex = dirname.indexOf('-'); if (dashIndex != -1) { dirname = dirname.substring(0, dashIndex); } if (!RESOURCE_TYPES.containsKey(dirname)) { throw new ResourceParseException("'%s' is not a valid resource sub-directory.", dir); } for (Path resourceFile : filesystem.getDirectoryContents(dir)) { if (shouldIgnoreFile(resourceFile, filesystem)) { continue; } String filename = resourceFile.getFileName().toString(); int dotIndex = filename.indexOf('.'); String resourceName = dotIndex != -1 ? filename.substring(0, dotIndex) : filename; RType rType = Preconditions.checkNotNull(RESOURCE_TYPES.get(dirname)); if (rType == RType.DRAWABLE) { processDrawables(filesystem, resourceFile); } else { resourceCollector.addIntResourceIfNotPresent(rType, resourceName); } } } void processDrawables(ProjectFilesystem filesystem, Path resourceFile) throws IOException, ResourceParseException { String filename = resourceFile.getFileName().toString(); int dotIndex = filename.indexOf('.'); String resourceName = dotIndex != -1 ? filename.substring(0, dotIndex) : filename; // Look into the XML file. boolean isGrayscaleImage = false; boolean isCustomDrawable = false; if (filename.endsWith(".xml")) { try (InputStream stream = filesystem.newFileInputStream(resourceFile)) { Document dom = parseXml(resourceFile, stream); Element root = dom.getDocumentElement(); isCustomDrawable = root.getNodeName().startsWith(CUSTOM_DRAWABLE_PREFIX); } } else if (isGrayscaleImageProcessingEnabled) { isGrayscaleImage = filename.endsWith(".g.png"); } if (isCustomDrawable) { resourceCollector.addCustomDrawableResourceIfNotPresent(RType.DRAWABLE, resourceName); } else if (isGrayscaleImage) { resourceCollector.addGrayscaleImageResourceIfNotPresent(RType.DRAWABLE, resourceName); } else { resourceCollector.addIntResourceIfNotPresent(RType.DRAWABLE, resourceName); } } void processValues(ProjectFilesystem filesystem, BuckEventBus eventBus, Path valuesDir) throws IOException, ResourceParseException { for (Path path : filesystem.getFilesUnderPath(valuesDir)) { if (shouldIgnoreFile(path, filesystem)) { continue; } if (!filesystem.isFile(path) && !filesystem.isIgnored(path)) { eventBus.post(ConsoleEvent.warning("MiniAapt [warning]: ignoring non-file '%s'.", path)); continue; } processValuesFile(filesystem, path); } } /** * Processes an {@code xml} file immediately under a {@code values} directory. See <a * href="http://developer.android.com/guide/topics/resources/more-resources.html>More Resource * Types</a> to find out more about how resources are defined. * * <p>For an input file with contents like: * * <pre> * <?xml version="1.0" encoding="utf-8"?> * <resources> * <integer name="number">42</integer> * <dimen name="dimension">10px</dimen> * <string name="hello">World</string> * <item name="my_fraction" type="fraction">1.5</item> * </resources> * </pre> * * the resulting resources would be: * * <ul> * <li>R.integer.number * <li>R.dimen.dimension * <li>R.string.hello * <li>R.fraction.my_fraction * </ul> */ @VisibleForTesting void processValuesFile(ProjectFilesystem filesystem, Path valuesFile) throws IOException, ResourceParseException { try (InputStream stream = filesystem.newFileInputStream(valuesFile)) { Document dom = parseXml(valuesFile, stream); Element root = dom.getDocumentElement(); // Exclude resources annotated with the attribute {@code exclude-from-resource-map}. // This is useful to exclude using generated strings to build the // resource map, which ensures a build break will show up at build time // rather than being hidden until generated resources are updated. if (root.getAttribute("exclude-from-buck-resource-map").equals("true")) { return; } for (Node node = root.getFirstChild(); node != null; node = node.getNextSibling()) { if (node.getNodeType() != Node.ELEMENT_NODE) { continue; } String resourceType = node.getNodeName(); if (resourceType.equals(ITEM_TAG)) { Node typeNode = node.getAttributes().getNamedItem("type"); if (typeNode == null) { throw new ResourceParseException( "Error parsing file '%s', expected a 'type' attribute in: \n'%s'\n", valuesFile, node.toString()); } resourceType = typeNode.getNodeValue(); } if (IGNORED_TAGS.contains(resourceType)) { continue; } if (!RESOURCE_TYPES.containsKey(resourceType)) { throw new ResourceParseException( "Invalid resource type '<%s>' in '%s'.", resourceType, valuesFile); } RType rType = Preconditions.checkNotNull(RESOURCE_TYPES.get(resourceType)); addToResourceCollector(node, rType); } } } private void addToResourceCollector(Node node, RType rType) throws ResourceParseException { String resourceName = sanitizeName(extractNameAttribute(node)); if (rType.equals(RType.STYLEABLE)) { int count = 0; for (Node attrNode = node.getFirstChild(); attrNode != null; attrNode = attrNode.getNextSibling()) { if (attrNode.getNodeType() != Node.ELEMENT_NODE || !attrNode.getNodeName().equals("attr")) { continue; } String rawAttrName = extractNameAttribute(attrNode); String attrName = sanitizeName(rawAttrName); resourceCollector.addResource( RType.STYLEABLE, IdType.INT, String.format("%s_%s", resourceName, attrName), Integer.toString(count++), resourceName); if (!rawAttrName.startsWith("android:")) { resourceCollector.addIntResourceIfNotPresent(RType.ATTR, attrName); } } resourceCollector.addIntArrayResourceIfNotPresent(rType, resourceName, count); } else { resourceCollector.addIntResourceIfNotPresent(rType, resourceName); } } void processXmlFilesForIds( ProjectFilesystem filesystem, ImmutableSet.Builder<RDotTxtEntry> references) throws IOException, XPathExpressionException, ResourceParseException { Path absoluteResDir = resolver.getAbsolutePath(resDirectory); Path relativeResDir = resolver.getRelativePath(resDirectory); for (Path path : filesystem.getFilesUnderPath(absoluteResDir, input -> input.toString().endsWith(".xml"))) { String dirname = relativeResDir.relativize(path).getName(0).toString(); if (isAValuesDir(dirname)) { // Ignore files under values* directories. continue; } processXmlFile(filesystem, path, references); } } @VisibleForTesting void processXmlFile( ProjectFilesystem filesystem, Path xmlFile, ImmutableSet.Builder<RDotTxtEntry> references) throws IOException, XPathExpressionException, ResourceParseException { try (InputStream stream = filesystem.newFileInputStream(xmlFile)) { Document dom = parseXml(xmlFile, stream); NodeList nodesWithIds = (NodeList) ANDROID_ID_DEFINITION.evaluate(dom, XPathConstants.NODESET); for (int i = 0; i < nodesWithIds.getLength(); i++) { String resourceName = nodesWithIds.item(i).getNodeValue(); if (!resourceName.startsWith(ID_DEFINITION_PREFIX)) { throw new ResourceParseException("Invalid definition of a resource: '%s'", resourceName); } Preconditions.checkState(resourceName.startsWith(ID_DEFINITION_PREFIX)); resourceCollector.addIntResourceIfNotPresent( RType.ID, resourceName.substring(ID_DEFINITION_PREFIX.length())); } NodeList nodesUsingIds = (NodeList) ANDROID_ID_USAGE.evaluate(dom, XPathConstants.NODESET); for (int i = 0; i < nodesUsingIds.getLength(); i++) { String resourceName = nodesUsingIds.item(i).getNodeValue(); int slashPosition = resourceName.indexOf('/'); if (resourceName.charAt(0) != '@' || slashPosition == -1) { throw new ResourceParseException("Invalid definition of a resource: '%s'", resourceName); } String rawRType = resourceName.substring(1, slashPosition); String name = resourceName.substring(slashPosition + 1); String nodeName = nodesUsingIds.item(i).getNodeName(); if (name.startsWith("android:") || nodeName.startsWith("tools:")) { continue; } if (!RESOURCE_TYPES.containsKey(rawRType)) { throw new ResourceParseException("Invalid reference '%s' in '%s'", resourceName, xmlFile); } RType rType = Preconditions.checkNotNull(RESOURCE_TYPES.get(rawRType)); references.add(new FakeRDotTxtEntry(IdType.INT, rType, sanitizeName(name))); } } } private static Document parseXml(Path filepath, InputStream inputStream) throws IOException, ResourceParseException { try { return XmlDomParser.parse(inputStream); } catch (SAXException e) { throw new ResourceParseException( "Error parsing xml file '%s': %s.", filepath, e.getMessage()); } } private static String extractNameAttribute(Node node) throws ResourceParseException { Node attribute = node.getAttributes().getNamedItem("name"); if (attribute == null) { throw new ResourceParseException( "Error: expected a 'name' attribute in node '%s' with value '%s'", node.getNodeName(), node.getTextContent()); } return attribute.getNodeValue(); } private static String sanitizeName(String rawName) { return rawName.replaceAll("[.:]", "_"); } private static boolean isAValuesDir(String dirname) { return dirname.equals("values") || dirname.startsWith("values-"); } private static boolean shouldIgnoreFile(Path path, ProjectFilesystem filesystem) throws IOException { return filesystem.isHidden(path) || IGNORED_FILE_EXTENSIONS.contains( com.google.common.io.Files.getFileExtension(path.getFileName().toString())) || AaptStep.isSilentlyIgnored(path); } @VisibleForTesting ImmutableSet<RDotTxtEntry> verifyReferences( ProjectFilesystem filesystem, ImmutableSet<RDotTxtEntry> references) throws IOException { ImmutableSet.Builder<RDotTxtEntry> unresolved = ImmutableSet.builder(); ImmutableSet.Builder<RDotTxtEntry> definitionsBuilder = ImmutableSet.builder(); definitionsBuilder.addAll(resourceCollector.getResources()); for (Path depRTxt : pathsToSymbolsOfDeps) { Iterable<String> lines = FluentIterable.from(filesystem.readLines(depRTxt)) .filter(input -> !Strings.isNullOrEmpty(input)) .toList(); for (String line : lines) { Optional<RDotTxtEntry> entry = RDotTxtEntry.parse(line); Preconditions.checkState(entry.isPresent()); definitionsBuilder.add(entry.get()); } } Set<RDotTxtEntry> definitions = definitionsBuilder.build(); for (RDotTxtEntry reference : references) { if (!definitions.contains(reference)) { unresolved.add(reference); } } return unresolved.build(); } @Override public String getShortName() { return "generate_resource_ids"; } @Override public String getDescription(ExecutionContext context) { return getShortName() + " " + resDirectory; } @SuppressWarnings("serial") @VisibleForTesting static class ResourceParseException extends Exception { ResourceParseException(String messageFormat, Object... args) { super(String.format(messageFormat, args)); } } }