/* * Copyright (C) 2012 The Android Open Source Project * * 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.android.utils; import static com.android.SdkConstants.AMP_ENTITY; import static com.android.SdkConstants.ANDROID_NS_NAME; import static com.android.SdkConstants.ANDROID_URI; import static com.android.SdkConstants.APOS_ENTITY; import static com.android.SdkConstants.APP_PREFIX; import static com.android.SdkConstants.GT_ENTITY; import static com.android.SdkConstants.LT_ENTITY; import static com.android.SdkConstants.QUOT_ENTITY; import static com.android.SdkConstants.XMLNS; import static com.android.SdkConstants.XMLNS_PREFIX; import static com.android.SdkConstants.XMLNS_URI; import static com.google.common.base.Charsets.UTF_16BE; import static com.google.common.base.Charsets.UTF_16LE; import static com.google.common.base.Charsets.UTF_8; import com.android.SdkConstants; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.google.common.io.Files; import org.w3c.dom.Attr; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.InputSource; import org.xml.sax.SAXException; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.io.StringReader; import java.util.HashSet; import java.util.Locale; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; /** XML Utilities */ public class XmlUtils { public static final String XML_COMMENT_BEGIN = "<!--"; //$NON-NLS-1$ public static final String XML_COMMENT_END = "-->"; //$NON-NLS-1$ public static final String XML_PROLOG = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"; //$NON-NLS-1$ /** * Separator for xml namespace and localname */ public static final char NS_SEPARATOR = ':'; //$NON-NLS-1$ /** * Returns the namespace prefix matching the requested namespace URI. * If no such declaration is found, returns the default "android" prefix for * the Android URI, and "app" for other URI's. By default the app namespace * will be created. If this is not desirable, call * {@link #lookupNamespacePrefix(Node, String, boolean)} instead. * * @param node The current node. Must not be null. * @param nsUri The namespace URI of which the prefix is to be found, * e.g. {@link SdkConstants#ANDROID_URI} * @return The first prefix declared or the default "android" prefix * (or "app" for non-Android URIs) */ @NonNull public static String lookupNamespacePrefix(@NonNull Node node, @NonNull String nsUri) { String defaultPrefix = ANDROID_URI.equals(nsUri) ? ANDROID_NS_NAME : APP_PREFIX; return lookupNamespacePrefix(node, nsUri, defaultPrefix, true /*create*/); } /** * Returns the namespace prefix matching the requested namespace URI. If no * such declaration is found, returns the default "android" prefix for the * Android URI, and "app" for other URI's. * * @param node The current node. Must not be null. * @param nsUri The namespace URI of which the prefix is to be found, e.g. * {@link SdkConstants#ANDROID_URI} * @param create whether the namespace declaration should be created, if * necessary * @return The first prefix declared or the default "android" prefix (or * "app" for non-Android URIs) */ @NonNull public static String lookupNamespacePrefix(@NonNull Node node, @NonNull String nsUri, boolean create) { String defaultPrefix = ANDROID_URI.equals(nsUri) ? ANDROID_NS_NAME : APP_PREFIX; return lookupNamespacePrefix(node, nsUri, defaultPrefix, create); } /** * Returns the namespace prefix matching the requested namespace URI. If no * such declaration is found, returns the default "android" prefix. * * @param node The current node. Must not be null. * @param nsUri The namespace URI of which the prefix is to be found, e.g. * {@link SdkConstants#ANDROID_URI} * @param defaultPrefix The default prefix (root) to use if the namespace is * not found. If null, do not create a new namespace if this URI * is not defined for the document. * @param create whether the namespace declaration should be created, if * necessary * @return The first prefix declared or the provided prefix (possibly with a * number appended to avoid conflicts with existing prefixes. */ public static String lookupNamespacePrefix( @Nullable Node node, @Nullable String nsUri, @Nullable String defaultPrefix, boolean create) { // Note: Node.lookupPrefix is not implemented in wst/xml/core NodeImpl.java // The following code emulates this simple call: // String prefix = node.lookupPrefix(NS_RESOURCES); // if the requested URI is null, it denotes an attribute with no namespace. if (nsUri == null) { return null; } // per XML specification, the "xmlns" URI is reserved if (XMLNS_URI.equals(nsUri)) { return XMLNS; } HashSet<String> visited = new HashSet<String>(); Document doc = node == null ? null : node.getOwnerDocument(); // Ask the document about it. This method may not be implemented by the Document. String nsPrefix = null; try { nsPrefix = doc != null ? doc.lookupPrefix(nsUri) : null; if (nsPrefix != null) { return nsPrefix; } } catch (Throwable t) { // ignore } // If that failed, try to look it up manually. // This also gathers prefixed in use in the case we want to generate a new one below. for (; node != null && node.getNodeType() == Node.ELEMENT_NODE; node = node.getParentNode()) { NamedNodeMap attrs = node.getAttributes(); for (int n = attrs.getLength() - 1; n >= 0; --n) { Node attr = attrs.item(n); if (XMLNS.equals(attr.getPrefix())) { String uri = attr.getNodeValue(); nsPrefix = attr.getLocalName(); // Is this the URI we are looking for? If yes, we found its prefix. if (nsUri.equals(uri)) { return nsPrefix; } visited.add(nsPrefix); } } } // Failed the find a prefix. Generate a new sensible default prefix, unless // defaultPrefix was null in which case the caller does not want the document // modified. if (defaultPrefix == null) { return null; } // // We need to make sure the prefix is not one that was declared in the scope // visited above. Pick a unique prefix from the provided default prefix. String prefix = defaultPrefix; String base = prefix; for (int i = 1; visited.contains(prefix); i++) { prefix = base + Integer.toString(i); } // Also create & define this prefix/URI in the XML document as an attribute in the // first element of the document. if (doc != null) { node = doc.getFirstChild(); while (node != null && node.getNodeType() != Node.ELEMENT_NODE) { node = node.getNextSibling(); } if (node != null && create) { // This doesn't work: //Attr attr = doc.createAttributeNS(XMLNS_URI, prefix); //attr.setPrefix(XMLNS); // // Xerces throws //org.w3c.dom.DOMException: NAMESPACE_ERR: An attempt is made to create or // change an object in a way which is incorrect with regard to namespaces. // // Instead pass in the concatenated prefix. (This is covered by // the UiElementNodeTest#testCreateNameSpace() test.) Attr attr = doc.createAttributeNS(XMLNS_URI, XMLNS_PREFIX + prefix); attr.setValue(nsUri); node.getAttributes().setNamedItemNS(attr); } } return prefix; } /** * Converts the given attribute value to an XML-attribute-safe value, meaning that * single and double quotes are replaced with their corresponding XML entities. * * @param attrValue the value to be escaped * @return the escaped value */ @NonNull public static String toXmlAttributeValue(@NonNull String attrValue) { for (int i = 0, n = attrValue.length(); i < n; i++) { char c = attrValue.charAt(i); if (c == '"' || c == '\'' || c == '<' || c == '&') { StringBuilder sb = new StringBuilder(2 * attrValue.length()); appendXmlAttributeValue(sb, attrValue); return sb.toString(); } } return attrValue; } /** * Converts the given XML-attribute-safe value to a java string * * @param escapedAttrValue the escaped value * @return the unescaped value */ @NonNull public static String fromXmlAttributeValue(@NonNull String escapedAttrValue) { String workingString = escapedAttrValue.replace(QUOT_ENTITY, "\""); workingString = workingString.replace(LT_ENTITY, "<"); workingString = workingString.replace(APOS_ENTITY, "'"); workingString = workingString.replace(AMP_ENTITY, "&"); workingString = workingString.replace(GT_ENTITY, ">"); return workingString; } /** * Converts the given attribute value to an XML-text-safe value, meaning that * less than and ampersand characters are escaped. * * @param textValue the text value to be escaped * @return the escaped value */ @NonNull public static String toXmlTextValue(@NonNull String textValue) { for (int i = 0, n = textValue.length(); i < n; i++) { char c = textValue.charAt(i); if (c == '<' || c == '&') { StringBuilder sb = new StringBuilder(2 * textValue.length()); appendXmlTextValue(sb, textValue); return sb.toString(); } } return textValue; } /** * Appends text to the given {@link StringBuilder} and escapes it as required for a * DOM attribute node. * * @param sb the string builder * @param attrValue the attribute value to be appended and escaped */ public static void appendXmlAttributeValue(@NonNull StringBuilder sb, @NonNull String attrValue) { int n = attrValue.length(); // &, ", ' and < are illegal in attributes; see http://www.w3.org/TR/REC-xml/#NT-AttValue // (' legal in a " string and " is legal in a ' string but here we'll stay on the safe // side) for (int i = 0; i < n; i++) { char c = attrValue.charAt(i); if (c == '"') { sb.append(QUOT_ENTITY); } else if (c == '<') { sb.append(LT_ENTITY); } else if (c == '\'') { sb.append(APOS_ENTITY); } else if (c == '&') { sb.append(AMP_ENTITY); } else { sb.append(c); } } } /** * Appends text to the given {@link StringBuilder} and escapes it as required for a * DOM text node. * * @param sb the string builder * @param textValue the text value to be appended and escaped */ public static void appendXmlTextValue(@NonNull StringBuilder sb, @NonNull String textValue) { for (int i = 0, n = textValue.length(); i < n; i++) { char c = textValue.charAt(i); if (c == '<') { sb.append(LT_ENTITY); } else if (c == '&') { sb.append(AMP_ENTITY); } else { sb.append(c); } } } /** * Returns true if the given node has one or more element children * * @param node the node to test for element children * @return true if the node has one or more element children */ public static boolean hasElementChildren(@NonNull Node node) { NodeList children = node.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { return true; } } return false; } /** * Returns a character reader for the given file, which must be a UTF encoded file. * <p> * The reader does not need to be closed by the caller (because the file is read in * full in one shot and the resulting array is then wrapped in a byte array input stream, * which does not need to be closed.) */ public static Reader getUtfReader(@NonNull File file) throws IOException { byte[] bytes = Files.toByteArray(file); int length = bytes.length; if (length == 0) { return new StringReader(""); } switch (bytes[0]) { case (byte)0xEF: { if (length >= 3 && bytes[1] == (byte)0xBB && bytes[2] == (byte)0xBF) { // UTF-8 BOM: EF BB BF: Skip it return new InputStreamReader(new ByteArrayInputStream(bytes, 3, length - 3), UTF_8); } break; } case (byte)0xFE: { if (length >= 2 && bytes[1] == (byte)0xFF) { // UTF-16 Big Endian BOM: FE FF return new InputStreamReader(new ByteArrayInputStream(bytes, 2, length - 2), UTF_16BE); } break; } case (byte)0xFF: { if (length >= 2 && bytes[1] == (byte)0xFE) { if (length >= 4 && bytes[2] == (byte)0x00 && bytes[3] == (byte)0x00) { // UTF-32 Little Endian BOM: FF FE 00 00 return new InputStreamReader(new ByteArrayInputStream(bytes, 4, length - 4), "UTF-32LE"); } // UTF-16 Little Endian BOM: FF FE return new InputStreamReader(new ByteArrayInputStream(bytes, 2, length - 2), UTF_16LE); } break; } case (byte)0x00: { if (length >= 4 && bytes[0] == (byte)0x00 && bytes[1] == (byte)0x00 && bytes[2] == (byte)0xFE && bytes[3] == (byte)0xFF) { // UTF-32 Big Endian BOM: 00 00 FE FF return new InputStreamReader(new ByteArrayInputStream(bytes, 4, length - 4), "UTF-32BE"); } break; } } // No byte order mark: Assume UTF-8 (where the BOM is optional). return new InputStreamReader(new ByteArrayInputStream(bytes), UTF_8); } /** * Parses the given XML string as a DOM document, using the JDK parser. The parser does not * validate, and is optionally namespace aware. * * @param xml the XML content to be parsed (must be well formed) * @param namespaceAware whether the parser is namespace aware * @return the DOM document */ @NonNull public static Document parseDocument(@NonNull String xml, boolean namespaceAware) throws ParserConfigurationException, IOException, SAXException { xml = stripBom(xml); DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); InputSource is = new InputSource(new StringReader(xml)); factory.setNamespaceAware(namespaceAware); factory.setValidating(false); DocumentBuilder builder = factory.newDocumentBuilder(); return builder.parse(is); } /** * Parses the given UTF file as a DOM document, using the JDK parser. The parser does not * validate, and is optionally namespace aware. * * @param file the UTF encoded file to parse * @param namespaceAware whether the parser is namespace aware * @return the DOM document */ @NonNull public static Document parseUtfXmlFile(@NonNull File file, boolean namespaceAware) throws ParserConfigurationException, IOException, SAXException { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); Reader reader = getUtfReader(file); try { InputSource is = new InputSource(reader); factory.setNamespaceAware(namespaceAware); factory.setValidating(false); DocumentBuilder builder = factory.newDocumentBuilder(); return builder.parse(is); } finally { reader.close(); } } /** Strips out a leading UTF byte order mark, if present */ @NonNull public static String stripBom(@NonNull String xml) { if (!xml.isEmpty() && xml.charAt(0) == '\uFEFF') { return xml.substring(1); } return xml; } /** * Parses the given XML string as a DOM document, using the JDK parser. The parser does not * validate, and is optionally namespace aware. Any parsing errors are silently ignored. * * @param xml the XML content to be parsed (must be well formed) * @param namespaceAware whether the parser is namespace aware * @return the DOM document, or null */ @Nullable public static Document parseDocumentSilently(@NonNull String xml, boolean namespaceAware) { try { return parseDocument(xml, namespaceAware); } catch (Exception e) { // pass // This method is deliberately silent; will return null } return null; } /** * Dump an XML tree to string. This does not perform any pretty printing. * To perform pretty printing, use {@code XmlPrettyPrinter.prettyPrint(node)} in * {@code sdk-common}. */ public static String toXml(Node node) { StringBuilder sb = new StringBuilder(1000); append(sb, node, 0); return sb.toString(); } /** Dump node to string without indentation adjustments */ private static void append( @NonNull StringBuilder sb, @NonNull Node node, int indent) { short nodeType = node.getNodeType(); switch (nodeType) { case Node.DOCUMENT_NODE: case Node.DOCUMENT_FRAGMENT_NODE: { sb.append(XML_PROLOG); NodeList children = node.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { append(sb, children.item(i), indent); } break; } case Node.COMMENT_NODE: sb.append(XML_COMMENT_BEGIN); sb.append(node.getNodeValue()); sb.append(XML_COMMENT_END); break; case Node.TEXT_NODE: { sb.append(toXmlTextValue(node.getNodeValue())); break; } case Node.CDATA_SECTION_NODE: { sb.append("<![CDATA["); //$NON-NLS-1$ sb.append(node.getNodeValue()); sb.append("]]>"); //$NON-NLS-1$ break; } case Node.ELEMENT_NODE: { sb.append('<'); Element element = (Element) node; sb.append(element.getTagName()); NamedNodeMap attributes = element.getAttributes(); NodeList children = element.getChildNodes(); int childCount = children.getLength(); int attributeCount = attributes.getLength(); if (attributeCount > 0) { for (int i = 0; i < attributeCount; i++) { Node attribute = attributes.item(i); sb.append(' '); sb.append(attribute.getNodeName()); sb.append('=').append('"'); sb.append(toXmlAttributeValue(attribute.getNodeValue())); sb.append('"'); } } if (childCount == 0) { sb.append('/'); } sb.append('>'); if (childCount > 0) { for (int i = 0; i < childCount; i++) { Node child = children.item(i); append(sb, child, indent + 1); } sb.append('<').append('/'); sb.append(element.getTagName()); sb.append('>'); } break; } default: throw new UnsupportedOperationException( "Unsupported node type " + nodeType + ": not yet implemented"); } } /** * Format the given floating value into an XML string, omitting decimals if * 0 * * @param value the value to be formatted * @return the corresponding XML string for the value */ public static String formatFloatAttribute(double value) { if (value != (int) value) { // Run String.format without a locale, because we don't want locale-specific // conversions here like separating the decimal part with a comma instead of a dot! return String.format((Locale) null, "%.2f", value); //$NON-NLS-1$ } else { return Integer.toString((int) value); } } }