| // 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.MacroXmlResourceValue; |
| 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.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 <resources> 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.buildOrThrow()); |
| } |
| |
| static XmlResourceValue parseStyle(XMLEventReader eventReader, StartElement start) |
| throws XMLStreamException { |
| Map<String, String> values = new LinkedHashMap<>(); |
| 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(), e); |
| } |
| } |
| 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("\"", """); |
| 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; |
| } |
| |
| static XmlResourceValue parseMacro( |
| XMLEventReader eventReader, StartElement start, Namespaces.Collector namespacesCollector) |
| throws XMLStreamException { |
| if (isEndTag(eventReader.peek(), start.getName())) { |
| throw new XMLStreamException( |
| String.format("<macro> must have contents %s", start), start.getLocation()); |
| } |
| |
| String contents = readContentsAsString(eventReader, start.getName(), namespacesCollector); |
| return MacroXmlResourceValue.of(contents); |
| } |
| |
| // 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("&", "&").replace("<", "<").replace(">", ">"); |
| } |
| |
| /** |
| * 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); |
| } |
| } |