| /* |
| * Copyright (C) 2011 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.manifmerger; |
| |
| import com.android.annotations.NonNull; |
| import com.android.annotations.Nullable; |
| import com.android.annotations.VisibleForTesting; |
| import com.android.manifmerger.IMergerLog.FileAndLine; |
| import com.android.manifmerger.IMergerLog.Severity; |
| import com.android.utils.ILogger; |
| import com.android.utils.XmlUtils; |
| |
| 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.xml.sax.ErrorHandler; |
| import org.xml.sax.InputSource; |
| import org.xml.sax.SAXParseException; |
| |
| import java.io.File; |
| import java.io.FileNotFoundException; |
| import java.io.Reader; |
| import java.io.StringWriter; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Map.Entry; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| import javax.xml.parsers.DocumentBuilder; |
| import javax.xml.parsers.DocumentBuilderFactory; |
| import javax.xml.transform.OutputKeys; |
| import javax.xml.transform.Transformer; |
| import javax.xml.transform.TransformerException; |
| import javax.xml.transform.TransformerFactory; |
| import javax.xml.transform.dom.DOMSource; |
| import javax.xml.transform.stream.StreamResult; |
| |
| /** |
| * A few XML handling utilities. |
| */ |
| class MergerXmlUtils { |
| |
| private static final String DATA_ORIGIN_FILE = "manif.merger.file"; //$NON-NLS-1$ |
| private static final String DATA_FILE_NAME = "manif.merger.filename"; //$NON-NLS-1$ |
| private static final String DATA_LINE_NUMBER = "manif.merger.line#"; //$NON-NLS-1$ |
| |
| /** |
| * Parses the given XML file as a DOM document. |
| * The parser does not validate the DTD nor any kind of schema. |
| * It is namespace aware. |
| * <p/> |
| * This adds a user tag with the original {@link File} to the returned document. |
| * You can retrieve this file later by using {@link #extractXmlFilename(Node)}. |
| * |
| * @param xmlFile The XML {@link File} to parse. Must not be null. |
| * @param log An {@link ILogger} for reporting errors. Must not be null. |
| * @param merger The {@link ManifestMerger} this document is intended for |
| * @return A new DOM {@link Document}, or null. |
| */ |
| @Nullable |
| static Document parseDocument(@NonNull final File xmlFile, @NonNull final IMergerLog log, |
| @NonNull ManifestMerger merger) { |
| try { |
| DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); |
| Reader reader = XmlUtils.getUtfReader(xmlFile); |
| InputSource is = new InputSource(reader); |
| factory.setNamespaceAware(true); |
| factory.setValidating(false); |
| DocumentBuilder builder = factory.newDocumentBuilder(); |
| |
| // We don't want the default handler which prints errors to stderr. |
| builder.setErrorHandler(new ErrorHandler() { |
| @Override |
| public void warning(SAXParseException e) { |
| log.error(Severity.WARNING, |
| new FileAndLine(xmlFile.getAbsolutePath(), 0), |
| "Warning when parsing: %1$s", |
| e.toString()); |
| } |
| @Override |
| public void fatalError(SAXParseException e) { |
| log.error(Severity.ERROR, |
| new FileAndLine(xmlFile.getAbsolutePath(), 0), |
| "Fatal error when parsing: %1$s", |
| xmlFile.getName(), e.toString()); |
| } |
| @Override |
| public void error(SAXParseException e) { |
| log.error(Severity.ERROR, |
| new FileAndLine(xmlFile.getAbsolutePath(), 0), |
| "Error when parsing: %1$s", |
| e.toString()); |
| } |
| }); |
| |
| Document doc = builder.parse(is); |
| doc.setUserData(DATA_ORIGIN_FILE, xmlFile, null /*handler*/); |
| findLineNumbers(doc, 1); |
| |
| if (merger.isInsertSourceMarkers()) { |
| setSource(doc, xmlFile); |
| } |
| |
| return doc; |
| |
| } catch (FileNotFoundException e) { |
| log.error(Severity.ERROR, |
| new FileAndLine(xmlFile.getAbsolutePath(), 0), |
| "XML file not found"); |
| |
| } catch (Exception e) { |
| log.error(Severity.ERROR, |
| new FileAndLine(xmlFile.getAbsolutePath(), 0), |
| "Failed to parse XML file: %1$s", |
| e.toString()); |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Parses the given XML string as a DOM document. |
| * The parser does not validate the DTD nor any kind of schema. |
| * It is namespace aware. |
| * |
| * @param xml The XML string to parse. Must not be null. |
| * @param log An {@link ILogger} for reporting errors. Must not be null. |
| * @return A new DOM {@link Document}, or null. |
| */ |
| @VisibleForTesting |
| @Nullable |
| static Document parseDocument(@NonNull String xml, |
| @NonNull IMergerLog log, |
| @NonNull FileAndLine errorContext) { |
| try { |
| Document doc = XmlUtils.parseDocument(xml, true); |
| findLineNumbers(doc, 1); |
| if (errorContext.getFileName() != null) { |
| setSource(doc, new File(errorContext.getFileName())); |
| } |
| return doc; |
| } catch (Exception e) { |
| log.error(Severity.ERROR, errorContext, "Failed to parse XML string"); |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Decorates the document with the specified file name, which can be |
| * retrieved later by calling {@link #extractLineNumber(Node)}. |
| * <p/> |
| * It also tries to add line number information, with the caveat that the |
| * current implementation is a gross approximation. |
| * <p/> |
| * There is no need to call this after calling one of the {@code parseDocument()} |
| * methods since they already decorated their own document. |
| * |
| * @param doc The document to decorate. |
| * @param fileName The name to retrieve later for that document. |
| */ |
| static void decorateDocument(@NonNull Document doc, @NonNull String fileName) { |
| doc.setUserData(DATA_FILE_NAME, fileName, null /*handler*/); |
| findLineNumbers(doc, 1); |
| } |
| |
| /** |
| * Returns a new {@link FileAndLine} structure that identifies |
| * the base filename & line number from which the XML node was parsed. |
| * <p/> |
| * When the line number is unknown (e.g. if a {@link Document} instance is given) |
| * then line number 0 will be used. |
| * |
| * @param node The node or document where the error occurs. Must not be null. |
| * @return A new non-null {@link FileAndLine} combining the file name and line number. |
| */ |
| @NonNull |
| static FileAndLine xmlFileAndLine(@NonNull Node node) { |
| String name = extractXmlFilename(node); |
| int line = extractLineNumber(node); // 0 in case of error or unknown |
| return new FileAndLine(name, line); |
| } |
| |
| /** |
| * Extracts the origin {@link File} that {@link #parseDocument(File, IMergerLog, |
| * ManifestMerger)} added to the XML document or the string added by |
| * |
| * @param xmlNode Any node from a document returned by {@link #parseDocument(File, IMergerLog, |
| * ManifestMerger)}. |
| * @return The {@link File} object used to create the document or null. |
| */ |
| @Nullable |
| static String extractXmlFilename(@Nullable Node xmlNode) { |
| if (xmlNode != null && xmlNode.getNodeType() != Node.DOCUMENT_NODE) { |
| xmlNode = xmlNode.getOwnerDocument(); |
| } |
| if (xmlNode != null) { |
| Object data = xmlNode.getUserData(DATA_ORIGIN_FILE); |
| if (data instanceof File) { |
| return ((File) data).getPath(); |
| } |
| data = xmlNode.getUserData(DATA_FILE_NAME); |
| if (data instanceof String) { |
| return (String) data; |
| } |
| } |
| |
| return null; |
| } |
| |
| public static void setSource(@NonNull Node node, @NonNull File source) { |
| //noinspection ConstantConditions |
| for (; node != null; node = node.getNextSibling()) { |
| short nodeType = node.getNodeType(); |
| if (nodeType == Node.ELEMENT_NODE |
| || nodeType == Node.COMMENT_NODE |
| || nodeType == Node.DOCUMENT_NODE |
| || nodeType == Node.CDATA_SECTION_NODE) { |
| node.setUserData(DATA_ORIGIN_FILE, source, null); |
| } |
| Node child = node.getFirstChild(); |
| setSource(child, source); |
| } |
| } |
| |
| /** |
| * This is a CRUDE INEXACT HACK to decorate the DOM with some kind of line number |
| * information for elements. It's inexact because by the time we get the DOM we |
| * already have lost all the information about whitespace between attributes. |
| * <p/> |
| * Also we don't even try to deal with \n vs \r vs \r\n insanity. This only counts |
| * the \n occurring in text nodes to determine line advances, which is clearly flawed. |
| * <p/> |
| * However it's good enough for testing, and we'll replace it by a PositionXmlParser |
| * once it's moved into com.android.util. |
| */ |
| private static int findLineNumbers(Node node, int line) { |
| for (; node != null; node = node.getNextSibling()) { |
| node.setUserData(DATA_LINE_NUMBER, Integer.valueOf(line), null /*handler*/); |
| |
| if (node.getNodeType() == Node.TEXT_NODE) { |
| String text = node.getNodeValue(); |
| if (!text.isEmpty()) { |
| for (int pos = 0; (pos = text.indexOf('\n', pos)) != -1; pos++) { |
| ++line; |
| } |
| } |
| } |
| |
| Node child = node.getFirstChild(); |
| if (child != null) { |
| line = findLineNumbers(child, line); |
| } |
| } |
| return line; |
| } |
| |
| /** |
| * Extracts the line number that {@link #findLineNumbers} added to the XML nodes. |
| * |
| * @param xmlNode Any node from a document returned by {@link #parseDocument(File, IMergerLog, |
| * ManifestMerger)}. |
| * @return The line number if found or 0. |
| */ |
| static int extractLineNumber(@Nullable Node xmlNode) { |
| if (xmlNode != null) { |
| Object data = xmlNode.getUserData(DATA_LINE_NUMBER); |
| if (data instanceof Integer) { |
| return ((Integer) data).intValue(); |
| } |
| } |
| |
| return 0; |
| } |
| |
| /** |
| * Outputs the given XML {@link Document} to the file {@code outFile}. |
| * |
| * TODO right now reformats the document. Needs to output as-is, respecting white-space. |
| * |
| * @param doc The document to output. Must not be null. |
| * @param outFile The {@link File} where to write the document. |
| * @param log A log in case of error. |
| * @return True if the file was written, false in case of error. |
| */ |
| static boolean printXmlFile( |
| @NonNull Document doc, |
| @NonNull File outFile, |
| @NonNull IMergerLog log) { |
| // Quick thing based on comments from http://stackoverflow.com/questions/139076 |
| try { |
| Transformer tf = TransformerFactory.newInstance().newTransformer(); |
| tf.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); //$NON-NLS-1$ |
| tf.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); //$NON-NLS-1$ |
| tf.setOutputProperty(OutputKeys.INDENT, "yes"); //$NON-NLS-1$ |
| tf.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", //$NON-NLS-1$ |
| "4"); //$NON-NLS-1$ |
| tf.transform(new DOMSource(doc), new StreamResult(outFile)); |
| return true; |
| } catch (TransformerException e) { |
| log.error(Severity.ERROR, |
| new FileAndLine(outFile.getName(), 0), |
| "Failed to write XML file: %1$s", |
| e.toString()); |
| return false; |
| } |
| } |
| |
| /** |
| * Outputs the given XML {@link Document} as a string. |
| * |
| * TODO right now reformats the document. Needs to output as-is, respecting white-space. |
| * |
| * @param doc The document to output. Must not be null. |
| * @param log A log in case of error. |
| * @return A string representation of the XML. Null in case of error. |
| */ |
| static String printXmlString( |
| @NonNull Document doc, |
| @NonNull IMergerLog log) { |
| try { |
| Transformer tf = TransformerFactory.newInstance().newTransformer(); |
| tf.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); //$NON-NLS-1$ |
| tf.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); //$NON-NLS-1$ |
| tf.setOutputProperty(OutputKeys.INDENT, "yes"); //$NON-NLS-1$ |
| tf.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", //$NON-NLS-1$ |
| "4"); //$NON-NLS-1$ |
| StringWriter sw = new StringWriter(); |
| tf.transform(new DOMSource(doc), new StreamResult(sw)); |
| return sw.toString(); |
| } catch (TransformerException e) { |
| log.error(Severity.ERROR, |
| new FileAndLine(extractXmlFilename(doc), 0), |
| "Failed to write XML file: %1$s", |
| e.toString()); |
| return null; |
| } |
| } |
| |
| /** |
| * Dumps the structure of the DOM to a simple text string. |
| * |
| * @param node The first node to dump (recursively). Can be null. |
| * @param nextSiblings If true, will also dump the following siblings. |
| * If false, it will just process the given node. |
| * @return A string representation of the Node structure, useful for debugging. |
| */ |
| @NonNull |
| static String dump(@Nullable Node node, boolean nextSiblings) { |
| return dump(node, 0 /*offset*/, nextSiblings, true /*deep*/, null /*keyAttr*/); |
| } |
| |
| |
| /** |
| * Dumps the structure of the DOM to a simple text string. |
| * Each line is terminated with a \n separator. |
| * |
| * @param node The first node to dump. Can be null. |
| * @param offsetIndex The offset to add at the begining of each line. Each offset is |
| * converted into 2 space characters. |
| * @param nextSiblings If true, will also dump the following siblings. |
| * If false, it will just process the given node. |
| * @param deep If true, this will recurse into children. |
| * @param keyAttr An optional attribute *local* name to insert when writing an element. |
| * For example when writing an Activity, it helps to always insert "name" attribute. |
| * @return A string representation of the Node structure, useful for debugging. |
| */ |
| @NonNull |
| static String dump( |
| @Nullable Node node, |
| int offsetIndex, |
| boolean nextSiblings, |
| boolean deep, |
| @Nullable String keyAttr) { |
| StringBuilder sb = new StringBuilder(); |
| |
| String offset = ""; //$NON-NLS-1$ |
| for (int i = 0; i < offsetIndex; i++) { |
| offset += " "; //$NON-NLS-1$ |
| } |
| |
| if (node == null) { |
| sb.append(offset).append("(end reached)\n"); |
| |
| } else { |
| for (; node != null; node = node.getNextSibling()) { |
| String type = null; |
| short t = node.getNodeType(); |
| switch(t) { |
| case Node.ELEMENT_NODE: |
| String attr = ""; |
| if (keyAttr != null) { |
| for (Node a : sortedAttributeList(node.getAttributes())) { |
| if (a != null && keyAttr.equals(a.getLocalName())) { |
| attr = String.format(" %1$s=%2$s", |
| a.getNodeName(), a.getNodeValue()); |
| break; |
| } |
| } |
| } |
| sb.append(String.format("%1$s<%2$s%3$s>\n", |
| offset, node.getNodeName(), attr)); |
| break; |
| case Node.COMMENT_NODE: |
| sb.append(String.format("%1$s<!-- %2$s -->\n", |
| offset, node.getNodeValue())); |
| break; |
| case Node.TEXT_NODE: |
| String txt = node.getNodeValue().trim(); |
| if (txt.isEmpty()) { |
| // Keep this for debugging. TODO make it a flag |
| // to dump whitespace on debugging. Otherwise ignore it. |
| // txt = "[whitespace]"; |
| break; |
| } |
| sb.append(String.format("%1$s%2$s\n", offset, txt)); |
| break; |
| case Node.ATTRIBUTE_NODE: |
| sb.append(String.format("%1$s @%2$s = %3$s\n", |
| offset, node.getNodeName(), node.getNodeValue())); |
| break; |
| case Node.CDATA_SECTION_NODE: |
| type = "cdata"; //$NON-NLS-1$ |
| break; |
| case Node.DOCUMENT_NODE: |
| type = "document"; //$NON-NLS-1$ |
| break; |
| case Node.PROCESSING_INSTRUCTION_NODE: |
| type = "PI"; //$NON-NLS-1$ |
| break; |
| default: |
| type = Integer.toString(t); |
| } |
| |
| if (type != null) { |
| sb.append(String.format("%1$s[%2$s] <%3$s> %4$s\n", |
| offset, type, node.getNodeName(), node.getNodeValue())); |
| } |
| |
| if (deep) { |
| for (Attr attr : sortedAttributeList(node.getAttributes())) { |
| sb.append(String.format("%1$s @%2$s = %3$s\n", |
| offset, attr.getNodeName(), attr.getNodeValue())); |
| } |
| |
| Node child = node.getFirstChild(); |
| if (child != null) { |
| sb.append(dump(child, offsetIndex+1, true, true, keyAttr)); |
| } |
| } |
| |
| if (!nextSiblings) { |
| break; |
| } |
| } |
| } |
| return sb.toString(); |
| } |
| |
| /** |
| * Returns a sorted list of attributes. |
| * The list is never null and does not contain null items. |
| * |
| * @param attrMap A Node map as returned by {@link Node#getAttributes()}. |
| * Can be null, in which case an empty list is returned. |
| * @return A non-null, possible empty, list of all nodes that are actual {@link Attr}, |
| * sorted by increasing attribute name. |
| */ |
| @NonNull |
| static List<Attr> sortedAttributeList(@Nullable NamedNodeMap attrMap) { |
| List<Attr> list = new ArrayList<Attr>(); |
| |
| if (attrMap != null) { |
| for (int i = 0; i < attrMap.getLength(); i++) { |
| Node attr = attrMap.item(i); |
| if (attr instanceof Attr) { |
| list.add((Attr) attr); |
| } |
| } |
| } |
| |
| if (list.size() > 1) { |
| // Sort it by attribute name |
| Collections.sort(list, getAttrComparator()); |
| } |
| |
| return list; |
| } |
| |
| /** |
| * Returns a comparator for {@link Attr}, alphabetically sorted by name. |
| * The "name" attribute is special and always sorted to the front. |
| */ |
| @NonNull |
| static Comparator<? super Attr> getAttrComparator() { |
| return new Comparator<Attr>() { |
| @Override |
| public int compare(Attr a1, Attr a2) { |
| String s1 = a1 == null ? "" : a1.getNodeName(); //$NON-NLS-1$ |
| String s2 = a2 == null ? "" : a2.getNodeName(); //$NON-NLS-1$ |
| |
| boolean name1 = s1.equals("name"); //$NON-NLS-1$ |
| boolean name2 = s2.equals("name"); //$NON-NLS-1$ |
| |
| if (name1 && name2) { |
| return 0; |
| } else if (name1) { |
| return -1; // name is always first |
| } else if (name2) { |
| return 1; // name is always first |
| } else { |
| return s1.compareTo(s2); |
| } |
| } |
| }; |
| } |
| |
| /** |
| * Inject attributes into an existing document. |
| * <p/> |
| * The map keys are "/manifest/elements...|attribute-ns-uri attribute-local-name", |
| * for example "/manifest/uses-sdk|http://schemas.android.com/apk/res/android minSdkVersion". |
| * (note the space separator between the attribute URI and its local name.) |
| * The elements will be created if they don't exists. Existing attributes will be modified. |
| * The replacement is done on the main document <em>before</em> merging. |
| * The value can be null to remove an existing attribute. |
| * |
| * @param doc The document to modify in-place. |
| * @param attributeMap A map of attributes to inject in the form [pseudo-xpath] => value. |
| * @param log A log in case of error. |
| */ |
| static void injectAttributes( |
| @Nullable Document doc, |
| @Nullable Map<String, String> attributeMap, |
| @NonNull IMergerLog log) { |
| if (doc == null || attributeMap == null || attributeMap.isEmpty()) { |
| return; |
| } |
| |
| // 1=path 2=URI 3=local name |
| final Pattern keyRx = Pattern.compile("^/([^\\|]+)\\|([^ ]*) +(.+)$"); //$NON-NLS-1$ |
| final FileAndLine docInfo = xmlFileAndLine(doc); |
| |
| nextAttribute: for (Entry<String, String> entry : attributeMap.entrySet()) { |
| String key = entry.getKey(); |
| String value = entry.getValue(); |
| if (key == null || key.isEmpty()) { |
| continue; |
| } |
| |
| Matcher m = keyRx.matcher(key); |
| if (!m.matches()) { |
| log.error(Severity.WARNING, docInfo, "Invalid injected attribute key: %s", key); |
| continue; |
| } |
| String path = m.group(1); |
| String attrNsUri = m.group(2); |
| String attrName = m.group(3); |
| |
| String[] segment = path.split(Pattern.quote("/")); //$NON-NLS-1$ |
| |
| // Get the path elements. Create them as needed if they don't exist. |
| Node element = doc; |
| nextSegment: for (int i = 0; i < segment.length; i++) { |
| // Find a child with the segment's name |
| String name = segment[i]; |
| for (Node child = element.getFirstChild(); |
| child != null; |
| child = child.getNextSibling()) { |
| if (child.getNodeType() == Node.ELEMENT_NODE && |
| child.getNamespaceURI() == null && |
| child.getNodeName().equals(name)) { |
| // Found it. Continue to the next inner segment. |
| element = child; |
| continue nextSegment; |
| } |
| } |
| // No such element. Create it. |
| if (value == null) { |
| // If value is null, we want to remove, not create and if can't find the |
| // element, then we're done: there's no such attribute to remove. |
| break nextAttribute; |
| } |
| |
| Element child = doc.createElement(name); |
| element = element.insertBefore(child, element.getFirstChild()); |
| } |
| |
| if (element == null) { |
| log.error(Severity.WARNING, docInfo, "Invalid injected attribute path: %s", path); |
| return; |
| } |
| |
| NamedNodeMap attrs = element.getAttributes(); |
| if (attrs != null) { |
| |
| |
| if (attrNsUri != null && attrNsUri.isEmpty()) { |
| attrNsUri = null; |
| } |
| Node attr = attrs.getNamedItemNS(attrNsUri, attrName); |
| |
| if (value == null) { |
| // We want to remove the attribute from the attribute map. |
| if (attr != null) { |
| attrs.removeNamedItemNS(attrNsUri, attrName); |
| } |
| |
| } else { |
| // We want to add or replace the attribute. |
| if (attr == null) { |
| attr = doc.createAttributeNS(attrNsUri, attrName); |
| if (attrNsUri != null) { |
| attr.setPrefix(XmlUtils.lookupNamespacePrefix(element, attrNsUri)); |
| } |
| attrs.setNamedItemNS(attr); |
| } |
| attr.setNodeValue(value); |
| } |
| } |
| } |
| } |
| |
| // ------- |
| |
| /** |
| * Flatten the element to a string. This "pretty prints" the XML tree starting |
| * from the given node and all its children and attributes. |
| * <p/> |
| * The output is designed to be printed using {@link #printXmlDiff}. |
| * |
| * @param node The root node to print. |
| * @param nsPrefix A map that is filled with all the URI=>prefix found. |
| * The internal string only contains the expanded URIs but this is rather verbose |
| * so when printing the diff these will be replaced by the prefixes collected here. |
| * @param prefix A "space" prefix added at the beginning of each line for indentation |
| * purposes. The diff printer later relies on this to find out the structure. |
| */ |
| @NonNull |
| static String printElement( |
| @NonNull Node node, |
| @NonNull Map<String, String> nsPrefix, |
| @NonNull String prefix) { |
| StringBuilder sb = new StringBuilder(); |
| sb.append(prefix).append('<'); |
| String uri = node.getNamespaceURI(); |
| if (uri != null) { |
| sb.append(uri).append(':'); |
| nsPrefix.put(uri, node.getPrefix()); |
| } |
| sb.append(node.getLocalName()); |
| printAttributes(sb, node, nsPrefix, prefix); |
| sb.append(">\n"); //$NON-NLS-1$ |
| printChildren(sb, node.getFirstChild(), true, nsPrefix, prefix + " "); //$NON-NLS-1$ |
| |
| sb.append(prefix).append("</"); //$NON-NLS-1$ |
| if (uri != null) { |
| sb.append(uri).append(':'); |
| } |
| sb.append(node.getLocalName()); |
| sb.append(">\n"); //$NON-NLS-1$ |
| |
| return sb.toString(); |
| } |
| |
| /** |
| * Flatten several children elements to a string. |
| * This is an implementation detail for {@link #printElement(Node, Map, String)}. |
| * <p/> |
| * If {@code nextSiblings} is false, the string conversion takes only the given |
| * child element and stops there. |
| * <p/> |
| * If {@code nextSiblings} is true, the string conversion also takes _all_ the siblings |
| * after the given element. The idea is the caller can call this with the first child |
| * of a parent and get a string showing all the children at the same time. They are |
| * sorted to avoid the ordering issue. |
| */ |
| @NonNull |
| private static StringBuilder printChildren( |
| @NonNull StringBuilder sb, |
| @NonNull Node child, |
| boolean nextSiblings, |
| @NonNull Map<String, String> nsPrefix, |
| @NonNull String prefix) { |
| ArrayList<String> children = new ArrayList<String>(); |
| |
| boolean hasText = false; |
| for (; child != null; child = child.getNextSibling()) { |
| short t = child.getNodeType(); |
| if (nextSiblings && t == Node.TEXT_NODE) { |
| // We don't typically have meaningful text nodes in an Android manifest. |
| // If there are, just dump them as-is into the element representation. |
| // We do trim whitespace and ignore all-whitespace or empty text nodes. |
| String s = child.getNodeValue().trim(); |
| if (!s.isEmpty()) { |
| sb.append(s); |
| hasText = true; |
| } |
| } else if (t == Node.ELEMENT_NODE) { |
| children.add(printElement(child, nsPrefix, prefix)); |
| if (!nextSiblings) { |
| break; |
| } |
| } |
| } |
| |
| if (hasText) { |
| sb.append('\n'); |
| } |
| |
| if (!children.isEmpty()) { |
| Collections.sort(children); |
| for (String s : children) { |
| sb.append(s); |
| } |
| } |
| |
| return sb; |
| } |
| |
| /** |
| * Flatten several attributes to a string using their alphabetical order. |
| * This is an implementation detail for {@link #printElement(Node, Map, String)}. |
| */ |
| @NonNull |
| private static StringBuilder printAttributes( |
| @NonNull StringBuilder sb, |
| @NonNull Node node, |
| @NonNull Map<String, String> nsPrefix, |
| @NonNull String prefix) { |
| ArrayList<String> attrs = new ArrayList<String>(); |
| |
| NamedNodeMap attrMap = node.getAttributes(); |
| if (attrMap != null) { |
| StringBuilder sb2 = new StringBuilder(); |
| for (int i = 0; i < attrMap.getLength(); i++) { |
| Node attr = attrMap.item(i); |
| if (attr instanceof Attr) { |
| sb2.setLength(0); |
| sb2.append('@'); |
| String uri = attr.getNamespaceURI(); |
| if (uri != null) { |
| sb2.append(uri).append(':'); |
| nsPrefix.put(uri, attr.getPrefix()); |
| } |
| sb2.append(attr.getLocalName()); |
| sb2.append("=\"").append(attr.getNodeValue()).append('\"'); //$NON-NLS-1$ |
| attrs.add(sb2.toString()); |
| } |
| } |
| } |
| |
| Collections.sort(attrs); |
| |
| for(String attr : attrs) { |
| sb.append('\n'); |
| sb.append(prefix).append(" ").append(attr); //$NON-NLS-1$ |
| } |
| return sb; |
| } |
| |
| //------------ |
| |
| /** |
| * Computes a quick diff between two strings generated by |
| * {@link #printElement(Node, Map, String)}. |
| * <p/> |
| * This is a <em>not</em> designed to be a full contextual diff. |
| * It just stops at the first difference found, printing up to 3 lines of diff |
| * and backtracking to add prior contextual information to understand the |
| * structure of the element where the first diff line occurred (by printing |
| * each parent found till the root one as well as printing the attribute |
| * named by {@code keyAttr}). |
| * |
| * @param sb The string builder where to output is written. |
| * @param expected The expected XML tree (as generated by {@link #printElement}.) |
| * For best result this would be the "destination" XML we're merging into, |
| * e.g. the main manifest. |
| * @param actual The actual XML tree (as generated by {@link #printElement}.) |
| * For best result this would be the "source" XML we're merging from, |
| * e.g. a library manifest. |
| * @param nsPrefixE The map of URI=>prefix for the expected XML tree. |
| * @param nsPrefixA The map of URI=>prefix for the actual XML tree. |
| * @param keyAttr An optional attribute *full* name (uri:local name) to always |
| * insert when writing the contextual lines before a diff line. |
| * For example when writing an Activity, it helps to always insert |
| * the "name" attribute since that's the key element to help the user |
| * identify which node is being dumped. |
| */ |
| static void printXmlDiff( |
| StringBuilder sb, |
| String expected, |
| String actual, |
| Map<String, String> nsPrefixE, |
| Map<String, String> nsPrefixA, |
| String keyAttr) { |
| String[] aE = expected.split("\n"); |
| String[] aA = actual.split("\n"); |
| int lE = aE.length; |
| int lA = aA.length; |
| int lm = lE < lA ? lA : lE; |
| boolean eofE = false; |
| boolean eofA = false; |
| boolean contextE = true; |
| boolean contextA = true; |
| int numDiff = 0; |
| |
| StringBuilder sE = new StringBuilder(); |
| StringBuilder sA = new StringBuilder(); |
| |
| outerLoop: for (int i = 0, iE = 0, iA = 0; i < lm; i++) { |
| if (iE < lE && iA < lA && aE[iE].equals(aA[iA])) { |
| if (numDiff > 0) { |
| // If we found a difference, stop now. |
| break outerLoop; |
| } |
| iE++; |
| iA++; |
| continue; |
| } else { |
| // Try to print some context for each side based on previous lines's space prefix. |
| if (contextE) { |
| if (iE > 0) { |
| String p = diffGetPrefix(aE[iE]); |
| for (int kE = iE-1; kE >= 0; kE--) { |
| if (!aE[kE].startsWith(p)) { |
| sE.insert(0, '\n').insert(0, diffReplaceNs(aE[kE], nsPrefixE)).insert(0, " "); |
| if (p.isEmpty()) { |
| break; |
| } |
| p = diffGetPrefix(aE[kE]); |
| } else if (aE[kE].contains(keyAttr) || kE == 0) { |
| sE.insert(0, '\n').insert(0, diffReplaceNs(aE[kE], nsPrefixE)).insert(0, " "); |
| } |
| } |
| } |
| contextE = false; |
| } |
| if (iE >= lE) { |
| if (!eofE) { |
| sE.append("--(end reached)\n"); |
| eofE = true; |
| } |
| } else { |
| sE.append("--").append(diffReplaceNs(aE[iE++], nsPrefixE)).append('\n'); |
| } |
| |
| if (contextA) { |
| if (iA > 0) { |
| String p = diffGetPrefix(aA[iA]); |
| for (int kA = iA-1; kA >= 0; kA--) { |
| if (!aA[kA].startsWith(p)) { |
| sA.insert(0, '\n').insert(0, diffReplaceNs(aA[kA], nsPrefixA)).insert(0, " "); |
| p = diffGetPrefix(aA[kA]); |
| if (p.isEmpty()) { |
| break; |
| } |
| } else if (aA[kA].contains(keyAttr) || kA == 0) { |
| sA.insert(0, '\n').insert(0, diffReplaceNs(aA[kA], nsPrefixA)).insert(0, " "); |
| } |
| } |
| } |
| contextA = false; |
| } |
| if (iA >= lA) { |
| if (!eofA) { |
| sA.append("++(end reached)\n"); |
| eofA = true; |
| } |
| } else { |
| sA.append("++").append(diffReplaceNs(aA[iA++], nsPrefixA)).append('\n'); |
| } |
| |
| // Dump up to 3 lines of difference |
| numDiff++; |
| if (numDiff == 3) { |
| break outerLoop; |
| } |
| } |
| } |
| |
| sb.append(sE); |
| sb.append(sA); |
| } |
| |
| /** |
| * Returns all the whitespace at the beginning of a string. |
| * Implementation details for {@link #printXmlDiff} used to find the "parent" |
| * element and include it in the context of the diff. |
| */ |
| private static String diffGetPrefix(String str) { |
| int pos = 0; |
| int len = str.length(); |
| while (pos < len && str.charAt(pos) == ' ') { |
| pos++; |
| } |
| return str.substring(0, pos); |
| } |
| |
| /** |
| * Simplifies a diff line by replacing NS URIs by their prefix. |
| * Implementation details for {@link #printXmlDiff}. |
| */ |
| private static String diffReplaceNs(String str, Map<String, String> nsPrefix) { |
| for (Entry<String, String> entry : nsPrefix.entrySet()) { |
| String uri = entry.getKey(); |
| String prefix = entry.getValue(); |
| if (prefix != null && str.contains(uri)) { |
| str = str.replaceAll(Pattern.quote(uri), Matcher.quoteReplacement(prefix)); |
| } |
| } |
| return str; |
| } |
| |
| /** |
| * Returns the file associated with the given specific node, if any. |
| * Note that this will not search upwards for parent nodes; it returns a |
| * file associated with this specific node, if any. |
| */ |
| @Nullable |
| public static File getFileFor(@NonNull Node node) { |
| return (File) node.getUserData(DATA_ORIGIN_FILE); |
| } |
| |
| /** |
| * Sets the file associated with the given node, if any |
| */ |
| public static void setFileFor(Node node, File file) { |
| node.setUserData(MergerXmlUtils.DATA_ORIGIN_FILE, file, null); |
| } |
| } |