blob: 1ea38826bf27e5d7a25e773bd98f51aa2e1a5658 [file] [log] [blame]
// 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.android.SdkConstants;
import com.android.resources.ResourceType;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.devtools.build.android.ParsedAndroidData.KeyValueConsumer;
import com.google.devtools.build.android.proto.SerializeFormat;
import com.google.devtools.build.android.xml.AttrXmlResourceValue;
import com.google.devtools.build.android.xml.IdXmlResourceValue;
import com.google.devtools.build.android.xml.Namespaces;
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 com.google.protobuf.CodedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.StringWriter;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.xml.namespace.QName;
import javax.xml.stream.XMLEventReader;
import javax.xml.stream.XMLEventWriter;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.events.Attribute;
import javax.xml.stream.events.Characters;
import javax.xml.stream.events.EndElement;
import javax.xml.stream.events.Namespace;
import javax.xml.stream.events.StartElement;
import javax.xml.stream.events.XMLEvent;
/**
* {@link XmlResourceValues} provides methods for getting {@link XmlResourceValue} derived classes.
*
* <p>Acts a static factory class containing the general xml parsing logic for resources that are
* declared inside the &lt;resources&gt; tag.
*/
public class XmlResourceValues {
private static final Logger logger = Logger.getLogger(XmlResourceValues.class.getCanonicalName());
private static final QName TAG_EAT_COMMENT = QName.valueOf("eat-comment");
private static final QName TAG_PLURALS = QName.valueOf("plurals");
private static final QName ATTR_QUANTITY = QName.valueOf("quantity");
private static final QName TAG_ATTR = QName.valueOf("attr");
private static final QName TAG_DECLARE_STYLEABLE = QName.valueOf("declare-styleable");
private static final QName TAG_ITEM = QName.valueOf("item");
private static final QName TAG_STYLE = QName.valueOf("style");
private static final QName TAG_SKIP = QName.valueOf("skip");
private static final QName TAG_RESOURCES = QName.valueOf("resources");
private static final QName ATTR_FORMAT = QName.valueOf("format");
private static final QName ATTR_NAME = QName.valueOf("name");
private static final QName ATTR_VALUE = QName.valueOf("value");
private static final QName ATTR_PARENT = QName.valueOf("parent");
private static final QName ATTR_TYPE = QName.valueOf("type");
private static final XMLOutputFactory XML_OUTPUT_FACTORY = XMLOutputFactory.newInstance();
private static XMLInputFactory inputFactoryInstance = null;
public static XMLInputFactory getXmlInputFactory() {
if (inputFactoryInstance == null) {
inputFactoryInstance = XMLInputFactory.newInstance();
inputFactoryInstance.setProperty(
"http://java.sun.com/xml/stream/properties/report-cdata-event", Boolean.TRUE);
}
return inputFactoryInstance;
}
static XmlResourceValue parsePlurals(
XMLEventReader eventReader, StartElement start, Namespaces.Collector namespacesCollector)
throws XMLStreamException {
ImmutableMap.Builder<String, String> values = ImmutableMap.builder();
namespacesCollector.collectFrom(start);
for (XMLEvent element = nextTag(eventReader);
!isEndTag(element, TAG_PLURALS);
element = nextTag(eventReader)) {
if (isItem(element)) {
if (!element.isStartElement()) {
throw new XMLStreamException(
String.format("Expected start element %s", element), element.getLocation());
}
String contents =
readContentsAsString(
eventReader, element.asStartElement().getName(), namespacesCollector);
values.put(
getElementAttributeByName(element.asStartElement(), ATTR_QUANTITY),
contents == null ? "" : contents);
}
}
return PluralXmlResourceValue.createWithAttributesAndValues(
ImmutableMap.copyOf(parseTagAttributes(start)), values.build());
}
static XmlResourceValue parseStyle(XMLEventReader eventReader, StartElement start)
throws XMLStreamException {
Map<String, String> values = new HashMap<>();
for (XMLEvent element = nextTag(eventReader);
!isEndTag(element, TAG_STYLE);
element = nextTag(eventReader)) {
if (isItem(element)) {
values.put(getElementName(element.asStartElement()), eventReader.getElementText());
}
}
// Parents can be declared as:
// ?style/parent
// @style/parent
// <Parent>
// And, in the resource name <parent>.<resource name>
// Here, we take a garbage in, garbage out approach and just read the xml value raw.
return StyleXmlResourceValue.of(getElementAttributeByName(start, ATTR_PARENT), values);
}
static void parseDeclareStyleable(
FullyQualifiedName.Factory fqnFactory,
Path path,
KeyValueConsumer<DataKey, DataResource> overwritingConsumer,
KeyValueConsumer<DataKey, DataResource> combiningConsumer,
XMLEventReader eventReader,
StartElement start)
throws XMLStreamException {
Map<FullyQualifiedName, Boolean> members = new LinkedHashMap<>();
for (XMLEvent element = nextTag(eventReader);
!isEndTag(element, TAG_DECLARE_STYLEABLE);
element = nextTag(eventReader)) {
if (isStartTag(element, TAG_ATTR)) {
StartElement attr = element.asStartElement();
FullyQualifiedName attrName = fqnFactory.create(ResourceType.ATTR, getElementName(attr));
// If there is format and the next tag is a starting tag, treat it as an attr definition.
// Without those, it will be an attr reference.
if (XmlResourceValues.getElementAttributeByName(attr, ATTR_FORMAT) != null
|| (XmlResourceValues.peekNextTag(eventReader) != null
&& XmlResourceValues.peekNextTag(eventReader).isStartElement())) {
overwritingConsumer.accept(
attrName, DataResourceXml.createWithNoNamespace(path, parseAttr(eventReader, attr)));
members.put(attrName, Boolean.TRUE);
} else {
members.put(attrName, Boolean.FALSE);
}
}
}
combiningConsumer.accept(
fqnFactory.create(ResourceType.STYLEABLE, getElementName(start)),
DataResourceXml.createWithNoNamespace(path, StyleableXmlResourceValue.of(members)));
}
static XmlResourceValue parseAttr(XMLEventReader eventReader, StartElement start)
throws XMLStreamException {
XmlResourceValue value =
AttrXmlResourceValue.from(
start, getElementAttributeByName(start, ATTR_FORMAT), eventReader);
return value;
}
static XmlResourceValue parseId(
XMLEventReader eventReader, StartElement start, Namespaces.Collector namespacesCollector)
throws XMLStreamException {
try {
if (XmlResourceValues.isEndTag(eventReader.peek(), start.getName())) {
return IdXmlResourceValue.of();
} else {
return IdXmlResourceValue.of(
readContentsAsString(
eventReader, start.getName(), namespacesCollector.collectFrom(start)));
}
} catch (IllegalArgumentException e) {
throw new XMLStreamException(e);
}
}
static XmlResourceValue parseSimple(
XMLEventReader eventReader,
ResourceType resourceType,
StartElement start,
Namespaces.Collector namespacesCollector)
throws XMLStreamException {
String contents;
namespacesCollector.collectFrom(start);
// Check that the element is unary. If it is, the contents is null
if (isEndTag(eventReader.peek(), start.getName())) {
contents = null;
} else {
contents = readContentsAsString(eventReader, start.getName(), namespacesCollector);
}
return SimpleXmlResourceValue.of(
start.getName().equals(TAG_ITEM)
? SimpleXmlResourceValue.Type.ITEM
: SimpleXmlResourceValue.Type.from(resourceType),
ImmutableMap.copyOf(parseTagAttributes(start)),
contents);
}
static XmlResourceValue parsePublic(
XMLEventReader eventReader, StartElement start, Namespaces.Collector namespacesCollector)
throws XMLStreamException {
namespacesCollector.collectFrom(start);
// The tag should be unary.
if (!isEndTag(eventReader.peek(), start.getName())) {
throw new XMLStreamException(
String.format("<public> tag should be unary %s", start), start.getLocation());
}
// The tag should have a valid type attribute, and optionally an id attribute.
ImmutableMap<String, String> attributes = ImmutableMap.copyOf(parseTagAttributes(start));
String typeAttr = attributes.get(SdkConstants.ATTR_TYPE);
ResourceType type;
if (typeAttr != null) {
type = ResourceType.getEnum(typeAttr);
if (type == null || type == ResourceType.PUBLIC) {
throw new XMLStreamException(
String.format("<public> tag has invalid type attribute %s", start),
start.getLocation());
}
} else {
throw new XMLStreamException(
String.format("<public> tag missing type attribute %s", start), start.getLocation());
}
String idValueAttr = attributes.get(SdkConstants.ATTR_ID);
Optional<Integer> id = Optional.absent();
if (idValueAttr != null) {
try {
id = Optional.of(Integer.decode(idValueAttr));
} catch (NumberFormatException e) {
throw new XMLStreamException(
String.format("<public> has invalid id number %s", start), start.getLocation());
}
}
if (attributes.size() > 2) {
throw new XMLStreamException(
String.format("<public> has unexpected attributes %s", start), start.getLocation());
}
return PublicXmlResourceValue.create(type, id);
}
public static Map<String, String> parseTagAttributes(StartElement start) {
// Using a map to deduplicate xmlns declarations on the attributes.
Map<String, String> attributeMap = new LinkedHashMap<>();
Iterator<Attribute> attributes = iterateAttributesFrom(start);
while (attributes.hasNext()) {
Attribute attribute = attributes.next();
QName name = attribute.getName();
// Name used as the resource key, so skip it here.
if (ATTR_NAME.equals(name)) {
continue;
}
String value = escapeXmlValues(attribute.getValue()).replace("\"", "&quot;");
if (!name.getNamespaceURI().isEmpty()) {
attributeMap.put(name.getPrefix() + ":" + attribute.getName().getLocalPart(), value);
} else {
attributeMap.put(attribute.getName().getLocalPart(), value);
}
Iterator<Namespace> namespaces = iterateNamespacesFrom(start);
while (namespaces.hasNext()) {
Namespace namespace = namespaces.next();
attributeMap.put("xmlns:" + namespace.getPrefix(), namespace.getNamespaceURI());
}
}
return attributeMap;
}
// TODO(corysmith): Replace this with real escaping system, preferably a performant high level xml
// writing library. See AndroidDataWritingVisitor TODO.
private static String escapeXmlValues(String data) {
return data.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
/**
* Reads the xml events as a string until finding a closing tag.
*
* @param eventReader The current xml stream.
* @param startTag The name of the tag to close on.
* @param namespacesCollector A builder for collecting namespaces.
* @return A xml escaped string representation of the xml stream
*/
@Nullable
public static String readContentsAsString(
XMLEventReader eventReader, QName startTag, Namespaces.Collector namespacesCollector)
throws XMLStreamException {
StringWriter contents = new StringWriter();
XMLEventWriter writer = XML_OUTPUT_FACTORY.createXMLEventWriter(contents);
while (!isEndTag(eventReader.peek(), startTag)) {
XMLEvent xmlEvent = (XMLEvent) eventReader.next();
if (xmlEvent.isStartElement()) {
namespacesCollector.collectFrom(xmlEvent.asStartElement());
writer.add(xmlEvent);
} else {
writer.add(xmlEvent);
}
}
// Verify the end element.
EndElement endElement = eventReader.nextEvent().asEndElement();
Preconditions.checkArgument(endElement.getName().equals(startTag));
return contents.toString();
}
@SuppressWarnings({
"unchecked"
}) // The interface returns Iterator, force casting based on documentation.
public static Iterator<Attribute> iterateAttributesFrom(StartElement start) {
return start.getAttributes();
}
@SuppressWarnings("unchecked")
public static Iterator<Namespace> iterateNamespacesFrom(StartElement start) {
return start.getNamespaces();
}
/* XML helper methods follow. */
// TODO(corysmith): Move these to a wrapper class for XMLEventReader.
@Nullable
public static String getElementAttributeByName(StartElement element, QName name) {
Attribute attribute = element.getAttributeByName(name);
return attribute == null ? null : attribute.getValue();
}
public static String getElementValue(StartElement start) {
return getElementAttributeByName(start, ATTR_VALUE);
}
public static String getElementName(StartElement start) {
return getElementAttributeByName(start, ATTR_NAME);
}
public static String getElementType(StartElement start) {
return getElementAttributeByName(start, ATTR_TYPE);
}
public static boolean isTag(XMLEvent event, QName subTagType) {
if (event.isStartElement()) {
return isStartTag(event, subTagType);
}
if (event.isEndElement()) {
return isEndTag(event, subTagType);
}
return false;
}
public static boolean isItem(XMLEvent start) {
return isTag(start, TAG_ITEM);
}
public static boolean isEndTag(XMLEvent event, QName subTagType) {
if (event.isEndElement()) {
return subTagType.equals(event.asEndElement().getName());
}
return false;
}
public static boolean isStartTag(XMLEvent event, QName subTagType) {
if (event.isStartElement()) {
return subTagType.equals(event.asStartElement().getName());
}
return false;
}
public static XMLEvent nextTag(XMLEventReader eventReader) throws XMLStreamException {
while (eventReader.hasNext()
&& !(eventReader.peek().isEndElement() || eventReader.peek().isStartElement())) {
XMLEvent nextEvent = eventReader.nextEvent();
if (nextEvent.isCharacters() && !nextEvent.asCharacters().isIgnorableWhiteSpace()) {
Characters characters = nextEvent.asCharacters();
// TODO(corysmith): Turn into a warning with the Path is available to add to it.
// This case is when unexpected characters are thrown into the xml. Best case, it's a
// incorrect comment type...
logger.fine(
String.format(
"Invalid characters [%s] found at %s",
characters.getData(), characters.getLocation().getLineNumber()));
}
}
return eventReader.nextEvent();
}
public static XMLEvent peekNextTag(XMLEventReader eventReader) throws XMLStreamException {
while (eventReader.hasNext()
&& !(eventReader.peek().isEndElement() || eventReader.peek().isStartElement())) {
eventReader.nextEvent();
}
return eventReader.peek();
}
@Nullable
static StartElement findNextStart(XMLEventReader eventReader) throws XMLStreamException {
while (eventReader.hasNext()) {
XMLEvent event = eventReader.nextEvent();
if (event.isStartElement()) {
return event.asStartElement();
}
}
return null;
}
static StartElement moveToResources(XMLEventReader eventReader) throws XMLStreamException {
while (eventReader.hasNext()) {
StartElement next = findNextStart(eventReader);
if (next != null && next.getName().equals(TAG_RESOURCES)) {
return next;
}
}
return null;
}
public static SerializeFormat.DataValue.Builder newSerializableDataValueBuilder(int sourceId) {
SerializeFormat.DataValue.Builder builder = SerializeFormat.DataValue.newBuilder();
return builder.setSourceId(sourceId);
}
public static int serializeProtoDataValue(
OutputStream output, SerializeFormat.DataValue.Builder builder) throws IOException {
SerializeFormat.DataValue value = builder.build();
value.writeDelimitedTo(output);
return CodedOutputStream.computeUInt32SizeNoTag(value.getSerializedSize())
+ value.getSerializedSize();
}
public static boolean isEatComment(StartElement start) {
return isTag(start, TAG_EAT_COMMENT);
}
public static boolean isSkip(StartElement start) {
return isTag(start, TAG_SKIP);
}
}