/* * 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.SdkConstants; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.manifmerger.IMergerLog.FileAndLine; import com.android.manifmerger.IMergerLog.Severity; import com.android.utils.SdkUtils; import com.android.utils.XmlUtils; import com.android.xml.AndroidXPathFactory; 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 java.io.File; import java.net.MalformedURLException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import javax.xml.xpath.XPath; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpressionException; /** * Merges a library manifest into a main application manifest. * <p/> * To use, create with {@link ManifestMerger#ManifestMerger(IMergerLog, ICallback)} then * call {@link ManifestMerger#process(File, File, File[], Map, String)}. * <p/> * <pre> Merge operations: * - root manifest: attributes ignored, warn if defined. * - application: * G- {@code @attributes}: most attributes are ignored in libs * except: application:name if defined, it must match. * except: application:agentBackup if defined, it must match. * (these represent class names and we don't want a lib to assume their app or backup * classes are being used when that will never be the case.) * C- activity / activity-alias / service / receiver / provider * => Merge as-is. Error if exists in the destination (same {@code @name}) * unless the definitions are exactly the same. * New elements are always merged at the end of the application element. * => Indicate if there's a dup. * D- uses-library * => Merge. OK if already exists same {@code @name}. * => Merge {@code @required}: true>false. * C- meta-data * => Merge as-is. Error if exists in the destination (same {@code @name}) * unless the definitions are exactly the same. * New elements are always merged at the end of the application element. * => Indicate if there's a dup. * A- instrumentation: * => Do not merge. ignore the ones from libs. * C- permission / permission-group / permission-tree: * => Merge as-is. Error if exists in the destination (same {@code @name}) * unless the definitions are exactly the same. * C- uses-permission: * => Add. OK if already defined. * E- uses-sdk: * {@code @minSdkVersion}: error if dest<lib. Never automatically change dest minsdk. * Codenames are accepted if we can resolve their API level. * {@code @targetSdkVersion}: warning if dest<lib. * Never automatically change dest targetsdk. * {@code @maxSdkVersion}: obsolete, ignored. Not used in comparisons and not merged. * D- uses-feature with {@code @name}: * => Merge with same {@code @name} * => Merge {@code @required}: true>false. * - Do not merge any {@code @glEsVersion} attribute at this point. * F- uses-feature with {@code @glEsVersion}: * => Error if defined in lib+dest with dest<lib. Never automatically change dest. * B- uses-configuration: * => There can be many. Error if source defines one that is not an exact match in dest. * (e.g. right now app must manually define something that matches exactly each lib) * B- supports-screens / compatible-screens: * => Do not merge. * => Error (warn?) if defined in lib and not strictly the same as in dest. * B- supports-gl-texture: * => Do not merge. Can have more than one. * => Error (warn?) if defined in lib and not present as-is in dest. * * Strategies: * A = Ignore, do not merge (no-op). * B = Do not merge but if defined in both must match equally. * C = Must not exist in dest or be exactly the same (key is the {@code @name} attribute). * D = Add new or merge with same key {@code @name}, adjust {@code @required} true>false. * E, F, G = Custom strategies; see above. * * What happens when merging libraries with conflicting information? * Say for example a main manifest has a minSdkVersion of 3, whereas libraries have * a minSdkVersion of 4 and 11. We could have 2 point of views: * - Play it safe: If we have a library with a minSdkVersion of 11, it means this * library code knows it can't work reliably on a lower API level. So the safest end * result would be a merged manifest with the highest minSdkVersion of all libraries. * - Trust the main manifest: When an app declares a given minSdkVersion, it also expects * to run a given range of devices. If we change the final minSdkVersion, the app won't * be available on as many devices as the developer might expect. And as a counterpoint * to issue 1, the app may be careful and not call the library without checking the * necessary features or APIs are available before hand. * Both points of views are conflicting. The solution taken here is to be conservative * and generate an error rather than merge and change a value that might be surprising. * On the other hand this can be problematic and force a developer to keep the main * manifest in sync with the libraries ones, in essence reducing the usefulness of the * automated merge to pure trivial cases. The idea is to just start this way and enhance * or revisit the mechanism later. * </pre> */ public class ManifestMerger { /** Logger object. Never null. */ private final IMergerLog mLog; /** An optional callback that the merger can use to query the calling SDK. */ private final ICallback mCallback; private XPath mXPath; private Document mMainDoc; /** Option to extract the package prefixes from the merged manifest. */ private boolean mExtractPackagePrefix; /** Whether the merger should insert comments pointing to the merge source files */ private boolean mInsertSourceMarkers; /** Namespace for Android attributes in an AndroidManifest.xml */ private static final String NS_URI = SdkConstants.NS_RESOURCES; /** Prefix for the Android namespace to use in XPath expressions. */ private static final String NS_PREFIX = AndroidXPathFactory.DEFAULT_NS_PREFIX; /** Namespace used in XML files for Android Tooling attributes */ private static final String TOOLS_URI = SdkConstants.TOOLS_URI; /** The name of the tool:merge attribute, to either override or ignore merges. */ private static final String MERGE_ATTR = "merge"; //$NON-NLS-1$ /** tool:merge="override" means to ignore what comes from libraries and only keep the * version from the main manifest. No conflict can be generated. */ private static final String MERGE_OVERRIDE = "override"; //$NON-NLS-1$ /** tool:merge="remove" means to remove a node and prevent merging -- not only is the * node from the libraries not merged, but the element is removed from the main manifest. */ private static final String MERGE_REMOVE = "remove"; //$NON-NLS-1$ /** * Sets of element/attribute that need to be treated as class names. * The attribute name must be the local name for the Android namespace. * For example "application/name" maps to <application android:name=...>. */ private static final String[] sClassAttributes = { "application/name", "application/backupAgent", "activity/name", "activity/parentActivityName", "activity-alias/name", "activity-alias/targetActivity", "receiver/name", "service/name", "provider/name", "instrumentation/name" }; /** * Creates a new {@link ManifestMerger}. * * @param log A non-null merger log to capture all warnings, errors and their location. * @param callback An optional callback that the merger can use to query the calling SDK. */ public ManifestMerger(@NonNull IMergerLog log, @Nullable ICallback callback) { mLog = log; mCallback = callback; } /** * Sets whether the manifest merger should extract package prefixes. * <p/> * When true, the merged document is revisited and class names attributes * are shortened when possible, e.g. the package prefix is removed from the * class name if it matches. * * @param extract If true, extract package prefixes. * @return this, for constructor chaining */ public ManifestMerger setExtractPackagePrefix(boolean extract) { mExtractPackagePrefix = extract; return this; } /** * Performs the merge operation. * <p/> * This does NOT stop on errors, in an attempt to accumulate as much * info as possible to return to the user. * Unless it failed to read the main manifest, a result file will be * created. However if process() returns false, the file should not * be used except for debugging purposes. * * @param outputFile The output path to generate. Can be the same as the main path. * @param mainFile The main manifest paths to read. What we merge into. * @param libraryFiles The library manifest paths to read. Must not be null. * @param injectAttributes A map of attributes to inject in the form [pseudo-xpath] => value. * The key is "/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. * @param packageOverride an optional package override. This only affects the package attribute, * all components (activities, receivers, etc...) are not affected by this. * @return True if the merge was completed, false otherwise. */ public boolean process( File outputFile, File mainFile, File[] libraryFiles, Map<String, String> injectAttributes, String packageOverride) { Document mainDoc = MergerXmlUtils.parseDocument(mainFile, mLog, this); if (mainDoc == null) { mLog.error(Severity.ERROR, new FileAndLine(mainFile.getAbsolutePath(), 0), "Failed to read manifest file."); return false; } boolean success = process(mainDoc, libraryFiles, injectAttributes, packageOverride); if (!MergerXmlUtils.printXmlFile(mainDoc, outputFile, mLog)) { mLog.error(Severity.ERROR, new FileAndLine(outputFile.getAbsolutePath(), 0), "Failed to write manifest file."); success = false; } return success; } /** * Performs the merge operation in-place in the given DOM. * <p/> * This does NOT stop on errors, in an attempt to accumulate as much * info as possible to return to the user. * <p/> * The method might modify the input XML document in-place for its own processing. * * @param mainDoc The document to merge into. Will be modified in-place. * @param libraryFiles The library manifest paths to read. Must not be null. * These will be modified in-place. * @param injectAttributes A map of attributes to inject in the form [pseudo-xpath] => value. * The key is "/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. * @param packageOverride an optional package override. This only affects the package attribute, * all components (activities, receivers, etc...) are not affected by this. * @return True on success, false if any error occurred (printed to the {@link IMergerLog}). */ public boolean process( Document mainDoc, File[] libraryFiles, Map<String, String> injectAttributes, String packageOverride) { boolean success = true; mMainDoc = mainDoc; MergerXmlUtils.decorateDocument(mainDoc, IMergerLog.MAIN_MANIFEST); MergerXmlUtils.injectAttributes(mainDoc, injectAttributes, mLog); String prefix = XmlUtils.lookupNamespacePrefix(mainDoc, SdkConstants.NS_RESOURCES); mXPath = AndroidXPathFactory.newXPath(prefix); expandFqcns(mainDoc); for (File libFile : libraryFiles) { Document libDoc = MergerXmlUtils.parseDocument(libFile, mLog, this); if (libDoc == null || !mergeLibDoc(cleanupToolsAttributes(libDoc))) { success = false; } } if (packageOverride != null) { MergerXmlUtils.injectAttributes(mainDoc, Collections.singletonMap("/manifest| package", packageOverride), mLog); } cleanupToolsAttributes(mainDoc); if (mExtractPackagePrefix) { extractFqcns(mainDoc); } if (mInsertSourceMarkers) { insertSourceMarkers(mainDoc); } mXPath = null; mMainDoc = null; return success; } /** * Performs the merge operation in-place in the given DOM. * <p/> * This does NOT stop on errors, in an attempt to accumulate as much * info as possible to return to the user. * <p/> * The method might modify the input XML documents in-place for its own processing. * * @param mainDoc The document to merge into. Will be modified in-place. * @param libraryDocs The library manifest documents to merge in. Must not be null. * These will be modified in-place. * @return True on success, false if any error occurred (printed to the {@link IMergerLog}). */ public boolean process(@NonNull Document mainDoc, @NonNull Document... libraryDocs) { boolean success = true; mMainDoc = mainDoc; MergerXmlUtils.decorateDocument(mainDoc, IMergerLog.MAIN_MANIFEST); String prefix = XmlUtils.lookupNamespacePrefix(mainDoc, SdkConstants.NS_RESOURCES); mXPath = AndroidXPathFactory.newXPath(prefix); expandFqcns(mainDoc); for (Document libDoc : libraryDocs) { MergerXmlUtils.decorateDocument(libDoc, IMergerLog.LIBRARY); if (!mergeLibDoc(cleanupToolsAttributes(libDoc))) { success = false; } } cleanupToolsAttributes(mainDoc); if (mExtractPackagePrefix) { extractFqcns(mainDoc); } if (mInsertSourceMarkers) { insertSourceMarkers(mainDoc); } mXPath = null; mMainDoc = null; return success; } // -------- /** * Merges the given library manifest into the destination manifest. * See {@link ManifestMerger} for merge details. * * @param libDoc The library document to merge from. Must not be null. * @return True on success, false if any error occurred (printed to the {@link IMergerLog}). */ private boolean mergeLibDoc(Document libDoc) { boolean err = false; expandFqcns(libDoc); // Strategy G (check <application> is compatible) err |= !checkApplication(libDoc); // Strategy B err |= !doNotMergeCheckEqual("/manifest/uses-configuration", libDoc); //$NON-NLS-1$ err |= !doNotMergeCheckEqual("/manifest/supports-screens", libDoc); //$NON-NLS-1$ err |= !doNotMergeCheckEqual("/manifest/compatible-screens", libDoc); //$NON-NLS-1$ err |= !doNotMergeCheckEqual("/manifest/supports-gl-texture", libDoc); //$NON-NLS-1$ boolean skipApplication = hasOverrideOrRemoveTag( findFirstElement(mMainDoc, "/manifest/application")); //$NON-NLS-1$ // Strategy C if (!skipApplication) { err |= !mergeNewOrEqual( "/manifest/application/activity", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, true); err |= !mergeNewOrEqual( "/manifest/application/activity-alias", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, true); err |= !mergeNewOrEqual( "/manifest/application/service", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, true); err |= !mergeNewOrEqual( "/manifest/application/receiver", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, true); err |= !mergeNewOrEqual( "/manifest/application/provider", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, true); } err |= !mergeNewOrEqual( "/manifest/permission", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, false); err |= !mergeNewOrEqual( "/manifest/permission-group", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, false); err |= !mergeNewOrEqual( "/manifest/permission-tree", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, false); err |= !mergeNewOrEqual( "/manifest/uses-permission", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, false); // Strategy D if (!skipApplication) { err |= !mergeAdjustRequired( "/manifest/application/uses-library", //$NON-NLS-1$ "name", //$NON-NLS-1$ "required", //$NON-NLS-1$ libDoc, null /*alternateKeyAttr*/); err |= !mergeNewOrEqual( "/manifest/application/meta-data", //$NON-NLS-1$ "name", //$NON-NLS-1$ libDoc, true); } err |= !mergeAdjustRequired( "/manifest/uses-feature", //$NON-NLS-1$ "name", //$NON-NLS-1$ "required", //$NON-NLS-1$ libDoc, "glEsVersion" /*alternateKeyAttr*/); // Strategy E err |= !checkSdkVersion(libDoc); // Strategy F err |= !checkGlEsVersion(libDoc); return !err; } /** * Expand all possible class names attributes in the given document. * <p/> * Some manifest attributes represent class names. These can be specified as fully * qualified class names or use a short notation consisting of just the terminal * class simple name or a dot followed by a partial class name. Unfortunately this * makes textual comparison of the attributes impossible. To simplify this, we can * modify the document to fully expand all these class names. The list of elements * and attributes to process is listed by {@link #sClassAttributes} and the expansion * simply consists of appending the manifest' package if defined. * * @param doc The document in which to expand potential FQCNs. */ private void expandFqcns(Document doc) { // Find the package attribute of the manifest. String pkg = null; Element manifest = findFirstElement(doc, "/manifest"); if (manifest != null) { pkg = manifest.getAttribute("package"); } if (pkg == null || pkg.isEmpty()) { // We can't adjust FQCNs if we don't know the root package name. // It's not a proper manifest if this is missing anyway. assert manifest != null; mLog.error(Severity.WARNING, xmlFileAndLine(manifest), "Missing 'package' attribute in manifest."); return; } for (String elementAttr : sClassAttributes) { String[] names = elementAttr.split("/"); if (names.length != 2) { continue; } String elemName = names[0]; String attrName = names[1]; NodeList elements = doc.getElementsByTagName(elemName); for (int i = 0; i < elements.getLength(); i++) { Node elem = elements.item(i); if (elem instanceof Element) { Attr attr = ((Element) elem).getAttributeNodeNS(NS_URI, attrName); if (attr != null) { String value = attr.getNodeValue(); // We know it's a shortened FQCN if it starts with a dot // or does not contain any dot. if (value != null && !value.isEmpty() && (value.indexOf('.') == -1 || value.charAt(0) == '.')) { if (value.charAt(0) == '.') { value = pkg + value; } else { value = pkg + '.' + value; } attr.setNodeValue(value); } } } } } } /** * Extracts the fully qualified class names from the manifest and uses the * prefix notation relative to the manifest package. This basically reverses * the effects of {@link #expandFqcns(Document)}, though of course it may * also remove prefixes which were inlined in the original documents. * * @param doc the document in which to extract the FQCNs. */ private void extractFqcns(Document doc) { // Find the package attribute of the manifest. String pkg = null; Element manifest = findFirstElement(doc, "/manifest"); if (manifest != null) { pkg = manifest.getAttribute("package"); } if (pkg == null || pkg.isEmpty()) { return; } int pkgLength = pkg.length(); for (String elementAttr : sClassAttributes) { String[] names = elementAttr.split("/"); if (names.length != 2) { continue; } String elemName = names[0]; String attrName = names[1]; NodeList elements = doc.getElementsByTagName(elemName); for (int i = 0; i < elements.getLength(); i++) { Node elem = elements.item(i); if (elem instanceof Element) { Attr attr = ((Element) elem).getAttributeNodeNS(NS_URI, attrName); if (attr != null) { String value = attr.getNodeValue(); // We know it's a shortened FQCN if it starts with a dot // or does not contain any dot. if (value != null && value.length() > pkgLength && value.startsWith(pkg) && value.charAt(pkgLength) == '.') { value = value.substring(pkgLength); attr.setNodeValue(value); } } } } } } /** * Checks (but does not merge) the application attributes using the following rules: * <pre> * - {@code @name}: Ignore if empty. Warning if its expanded FQCN doesn't match the main doc. * - {@code @backupAgent}: Ignore if empty. Warning if its expanded FQCN doesn't match main doc. * - All other attributes are ignored. * </pre> * The name and backupAgent represent classes and the merger will warn since if a lib has * these defined they will never be used anyway. * @param libDoc The library document to merge from. Must not be null. * @return True on success, false if any error occurred (printed to the {@link IMergerLog}). */ private boolean checkApplication(Document libDoc) { Element mainApp = findFirstElement(mMainDoc, "/manifest/application"); //$NON-NLS-1$ Element libApp = findFirstElement(libDoc, "/manifest/application"); //$NON-NLS-1$ // A manifest does not necessarily define an application. // If the lib has none, there's nothing to check for. if (libApp == null) { return true; } if (hasOverrideOrRemoveTag(mainApp)) { // Don't check the <application> element since it is tagged with override or remove. return true; } for (String attrName : new String[] { "name", "backupAgent" }) { String libValue = getAttributeValue(libApp, attrName); if (libValue == null || libValue.isEmpty()) { // Nothing to do if the attribute is not defined in the lib. continue; } // The main doc does not have to have an application node. String mainValue = mainApp == null ? "" : getAttributeValue(mainApp, attrName); if (!libValue.equals(mainValue)) { assert mainApp != null; mLog.conflict(Severity.WARNING, xmlFileAndLine(mainApp), xmlFileAndLine(libApp), mainApp == null ? "Library has <application android:%1$s='%3$s'> but main manifest has no application element." : "Main manifest has <application android:%1$s='%2$s'> but library uses %1$s='%3$s'.", attrName, mainValue, libValue); } } return true; } /** * Do not merge anything. Instead it checks that the requested elements from the * given library are all present and equal in the destination and prints a warning * if it's not the case. * <p/> * For example if a library supports a given screen configuration, print a * warning if the main manifest doesn't indicate the app supports the same configuration. * We should not merge it since we don't want to silently give the impression an app * supports a configuration just because it uses a library which does. * On the other hand we don't want to silently ignore this fact. * <p/> * TODO there should be a way to silence this warning. * The current behavior is certainly arbitrary and needs to be tweaked somehow. * * @param path The XPath of the elements to merge from the library. Must not be null. * @param libDoc The library document to merge from. Must not be null. * @return True on success, false if any error occurred (printed to the {@link IMergerLog}). */ private boolean doNotMergeCheckEqual(String path, Document libDoc) { for (Element src : findElements(libDoc, path)) { boolean found = false; for (Element dest : findElements(mMainDoc, path)) { if (hasOverrideOrRemoveTag(dest)) { continue; } if (compareElements(dest, src, false, null /*diff*/, null /*keyAttr*/)) { found = true; break; } } if (!found) { mLog.conflict(Severity.WARNING, xmlFileAndLine(mMainDoc), xmlFileAndLine(src), "%1$s defined in library, missing from main manifest:\n%2$s", path, MergerXmlUtils.dump(src, false /*nextSiblings*/)); } } return true; } /** * Merges the requested elements from the library in the main document. * The key attribute name is used to identify the same elements. * Merged elements must either not exist in the destination or be identical. * <p/> * When merging, append to the end of the application element. * Also merges any preceding whitespace and up to one comment just prior to the merged element. * * @param path The XPath of the elements to merge from the library. Must not be null. * @param keyAttr The Android-namespace attribute used as key to identify similar elements. * E.g. "name" for "android:name" * @param libDoc The library document to merge from. Must not be null. * @param warnDups When true, will print a warning when a library definition is already * present in the destination and is equal. * @return True on success, false if any error occurred (printed to the {@link IMergerLog}). */ private boolean mergeNewOrEqual( String path, String keyAttr, Document libDoc, boolean warnDups) { // The parent of XPath /p1/p2/p3 is /p1/p2. To find it, delete the last "/segment" int pos = path.lastIndexOf('/'); assert pos > 1; String parentPath = path.substring(0, pos); Element parent = findFirstElement(mMainDoc, parentPath); assert parent != null; if (parent == null) { mLog.error(Severity.ERROR, xmlFileAndLine(mMainDoc), "Could not find element %1$s.", parentPath); return false; } boolean success = true; nextSource: for (Element src : findElements(libDoc, path)) { String name = getAttributeValue(src, keyAttr); if (name.isEmpty()) { mLog.error(Severity.ERROR, xmlFileAndLine(src), "Undefined '%1$s' attribute in %2$s.", keyAttr, path); success = false; continue; } // Look for the same item in the destination List<Element> dests = findElements(mMainDoc, path, keyAttr, name); if (dests.size() > 1) { // This should not be happening. We'll just use the first one found in this case. mLog.error(Severity.WARNING, xmlFileAndLine(dests.get(0)), "Manifest has more than one %1$s[@%2$s=%3$s] element.", path, keyAttr, name); } boolean doMerge = true; for (Element dest : dests) { // Don't try to merge this element since it has tools:merge=override|remove. if (hasOverrideOrRemoveTag(dest)) { doMerge = false; continue; } // If there's already a similar node in the destination, check it's identical. StringBuilder diff = new StringBuilder(); if (compareElements(dest, src, false, diff, keyAttr)) { // Same element. Skip. if (warnDups) { mLog.conflict(Severity.INFO, xmlFileAndLine(dest), xmlFileAndLine(src), "Skipping identical %1$s[@%2$s=%3$s] element.", path, keyAttr, name); } continue nextSource; } else { // Print the diff we got from the comparison. mLog.conflict(Severity.ERROR, xmlFileAndLine(dest), xmlFileAndLine(src), "Trying to merge incompatible %1$s[@%2$s=%3$s] element:\n%4$s", path, keyAttr, name, diff.toString()); success = false; continue nextSource; } } if (doMerge) { // Ready to merge element src. Select which previous siblings to merge. Node start = selectPreviousSiblings(src); insertAtEndOf(parent, start, src); } } return success; } /** * Returns the value of the given "android:attribute" in the given element. * * @param element The non-null element where to extract the attribute. * @param attrName The local name of the attribute. * It must use the {@link #NS_URI} but no prefix should be specified here. * @return The value of the attribute or a non-null empty string if not found. */ private String getAttributeValue(Element element, String attrName) { Attr attr = element.getAttributeNodeNS(NS_URI, attrName); String value = attr == null ? "" : attr.getNodeValue(); //$NON-NLS-1$ return value; } /** * Merge elements as identified by their key name attribute. * The element must have an option boolean "required" attribute which can be either "true" or * "false". Default is true if the attribute is missing. When merging, a "false" is superseded * by a "true" (explicit or implicit). * <p/> * When merging, this does NOT merge any other attributes than {@code keyAttr} and * {@code requiredAttr}. * * @param path The XPath of the elements to merge from the library. Must not be null. * @param keyAttr The Android-namespace attribute used as key to identify similar elements. * E.g. "name" for "android:name" * @param requiredAttr The name of the Android-namespace boolean attribute that must be merged. * Typically should be "required". * @param libDoc The library document to merge from. Must not be null. * @param alternateKeyAttr When non-null, this is an alternate valid key attribute. If the * default key attribute is missing, we won't output a warning if the alternate one is * present. * @return True on success, false if any error occurred (printed to the {@link IMergerLog}). */ private boolean mergeAdjustRequired( String path, String keyAttr, String requiredAttr, Document libDoc, @Nullable String alternateKeyAttr) { // The parent of XPath /p1/p2/p3 is /p1/p2. To find it, delete the last "/segment" int pos = path.lastIndexOf('/'); assert pos > 1; String parentPath = path.substring(0, pos); Element parent = findFirstElement(mMainDoc, parentPath); assert parent != null; if (parent == null) { mLog.error(Severity.ERROR, xmlFileAndLine(mMainDoc), "Could not find element %1$s.", parentPath); return false; } boolean success = true; for (Element src : findElements(libDoc, path)) { Attr attr = src.getAttributeNodeNS(NS_URI, keyAttr); String name = attr == null ? "" : attr.getNodeValue().trim(); //$NON-NLS-1$ if (name.isEmpty()) { if (alternateKeyAttr != null) { attr = src.getAttributeNodeNS(NS_URI, alternateKeyAttr); String s = attr == null ? "" : attr.getNodeValue().trim(); //$NON-NLS-1$ if (!s.isEmpty()) { // This element lacks the keyAttr but has the alternateKeyAttr. Skip it. continue; } } mLog.error(Severity.ERROR, xmlFileAndLine(src), "Undefined '%1$s' attribute in %2$s.", keyAttr, path); success = false; continue; } // Look for the same item in the destination List<Element> dests = findElements(mMainDoc, path, keyAttr, name); if (dests.size() > 1) { // This should not be happening. We'll just use the first one found in this case. mLog.error(Severity.WARNING, xmlFileAndLine(dests.get(0)), "Manifest has more than one %1$s[@%2$s=%3$s] element.", path, keyAttr, name); } if (!dests.isEmpty()) { attr = src.getAttributeNodeNS(NS_URI, requiredAttr); String value = attr == null ? "true" : attr.getNodeValue(); //$NON-NLS-1$ if (value == null || !(value.equals("true") || value.equals("false"))) { mLog.error(Severity.WARNING, xmlFileAndLine(src), "Invalid attribute '%1$s' in %2$s[@%3$s=%4$s] element:\nExpected 'true' or 'false' but found '%5$s'.", requiredAttr, path, keyAttr, name, value); continue; } boolean boolE = Boolean.parseBoolean(value); for (Element dest : dests) { // Don't try to merge this element since it has tools:merge=override|remove. if (hasOverrideOrRemoveTag(dest)) { continue; } // Compare the required attributes. attr = dest.getAttributeNodeNS(NS_URI, requiredAttr); value = attr == null ? "true" : attr.getNodeValue(); //$NON-NLS-1$ if (value == null || !(value.equals("true") || value.equals("false"))) { mLog.error(Severity.WARNING, xmlFileAndLine(dest), "Invalid attribute '%1$s' in %2$s[@%3$s=%4$s] element:\nExpected 'true' or 'false' but found '%5$s'.", requiredAttr, path, keyAttr, name, value); continue; } boolean boolD = Boolean.parseBoolean(value); if (!boolD && boolE) { // Required attributes differ: destination is false and source was true // so we need to change the destination to true. // If attribute was already in the destination, change it in place if (attr != null) { attr.setNodeValue("true"); //$NON-NLS-1$ } else { // Otherwise, do nothing. The destination doesn't have the // required=true attribute, and true is the default value. // Consequently not setting is the right thing to do. // -- code snippet for reference -- // If we wanted to create a new attribute, we'd use the code // below. There's a simpler call to d.setAttributeNS(ns, name, value) // but experience shows that it would create a new prefix out of the // blue instead of looking it up. // // Attr a=d.getOwnerDocument().createAttributeNS(NS_URI, requiredAttr); // String prefix = d.lookupPrefix(NS_URI); // if (prefix != null) { // a.setPrefix(prefix); // } // a.setValue("true"); //$NON-NLS-1$ // d.setAttributeNodeNS(attr); } } } } else { // Destination doesn't exist. We simply merge the source element. // Select which previous siblings to merge. Node start = selectPreviousSiblings(src); Node node = insertAtEndOf(parent, start, src); NamedNodeMap attrs = node.getAttributes(); if (attrs != null) { for (int i = 0; i < attrs.getLength(); i++) { Node a = attrs.item(i); if (a.getNodeType() == Node.ATTRIBUTE_NODE) { boolean keep = NS_URI.equals(a.getNamespaceURI()); if (keep) { name = a.getLocalName(); keep = keyAttr.equals(name) || requiredAttr.equals(name); } if (!keep) { attrs.removeNamedItemNS(NS_URI, name); // Restart the loop from index 0 since there's no // guarantee on the order of the nodes in the "map". // This makes it O(n+2n) at most, where n is [2..3] in // a typical case. i = -1; } } } } } } return success; } /** * Checks (but does not merge) uses-feature glEsVersion attribute using the following rules: * <pre> * - Error if defined in lib+dest with dest<lib. * - Never automatically change dest. * - Default implied value is 1.0 (0x00010000). * </pre> * * @param libDoc The library document to merge from. Must not be null. * @return True on success, false if any error occurred (printed to the {@link IMergerLog}). */ private boolean checkGlEsVersion(Document libDoc) { String parentPath = "/manifest"; //$NON-NLS-1$ Element parent = findFirstElement(mMainDoc, parentPath); assert parent != null; if (parent == null) { mLog.error(Severity.ERROR, xmlFileAndLine(mMainDoc), "Could not find element %1$s.", parentPath); return false; } // Find the max glEsVersion on the destination side String path = "/manifest/uses-feature"; //$NON-NLS-1$ String keyAttr = "glEsVersion"; //$NON-NLS-1$ long destGlEsVersion = 0x00010000L; // default minimum is 1.0 Element destNode = null; boolean result = true; for (Element dest : findElements(mMainDoc, path)) { Attr attr = dest.getAttributeNodeNS(NS_URI, keyAttr); String value = attr == null ? "" : attr.getNodeValue().trim(); //$NON-NLS-1$ if (!value.isEmpty()) { try { // Note that the value can be an hex number such as 0x00020001 so we // need Integer.decode instead of Integer.parseInt. // Note: Integer.decode cannot handle "ffffffff", see JDK issue 6624867 // so we just treat the version as a long and test like this, ignoring // the fact that a value of 0xFFFF/.0xFFFF is probably invalid anyway // in the context of glEsVersion. long version = Long.decode(value); if (version >= destGlEsVersion) { destGlEsVersion = version; destNode = dest; } else if (version < 0x00010000) { mLog.error(Severity.WARNING, xmlFileAndLine(dest), "Ignoring <uses-feature android:glEsVersion='%1$s'> because it's smaller than 1.0.", value); } } catch (NumberFormatException e) { // Note: NumberFormatException.toString() has no interesting information // so we don't output it. mLog.error(Severity.ERROR, xmlFileAndLine(dest), "Failed to parse <uses-feature android:glEsVersion='%1$s'>: must be an integer in the form 0x00020001.", value); result = false; } } } // If we found at least one valid with no error, use that, otherwise bail out. if (!result && destNode == null) { return false; } // Now find the max glEsVersion on the source side. long srcGlEsVersion = 0x00010000L; // default minimum is 1.0 Element srcNode = null; result = true; for (Element src : findElements(libDoc, path)) { Attr attr = src.getAttributeNodeNS(NS_URI, keyAttr); String value = attr == null ? "" : attr.getNodeValue().trim(); //$NON-NLS-1$ if (!value.isEmpty()) { try { // See comment on Long.decode above. long version = Long.decode(value); if (version >= srcGlEsVersion) { srcGlEsVersion = version; srcNode = src; } else if (version < 0x00010000) { mLog.error(Severity.WARNING, xmlFileAndLine(src), "Ignoring <uses-feature android:glEsVersion='%1$s'> because it's smaller than 1.0.", value); } } catch (NumberFormatException e) { // Note: NumberFormatException.toString() has no interesting information // so we don't output it. mLog.error(Severity.ERROR, xmlFileAndLine(src), "Failed to parse <uses-feature android:glEsVersion='%1$s'>: must be an integer in the form 0x00020001.", value); result = false; } } } if (srcNode != null && destGlEsVersion < srcGlEsVersion) { mLog.conflict(Severity.WARNING, xmlFileAndLine(destNode == null ? mMainDoc : destNode), xmlFileAndLine(srcNode), "Main manifest has <uses-feature android:glEsVersion='0x%1$08x'> but library uses glEsVersion='0x%2$08x'%3$s", destGlEsVersion, srcGlEsVersion, destNode != null ? "" : //$NON-NLS-1$ "\nNote: main manifest lacks a <uses-feature android:glEsVersion> declaration, and thus defaults to glEsVersion=0x00010000." ); result = false; } return result; } /** * Checks (but does not merge) uses-sdk attributes using the following rules: * <pre> * - {@code @minSdkVersion}: error if dest<lib. Never automatically change dest minsdk. * - {@code @targetSdkVersion}: warning if dest<lib. Never automatically change destination. * - {@code @maxSdkVersion}: obsolete, ignored. Not used in comparisons and not merged. * - The API level can be a codename if we have a callback that can convert it to an integer. * </pre> * @param libDoc The library document to merge from. Must not be null. * @return True on success, false if any error occurred (printed to the {@link IMergerLog}). */ private boolean checkSdkVersion(Document libDoc) { boolean result = true; Element destUsesSdk = findFirstElement(mMainDoc, "/manifest/uses-sdk"); //$NON-NLS-1$ if (hasOverrideOrRemoveTag(destUsesSdk)) { // Don't try to check this element since it has tools:merge=override|remove. return true; } Element srcUsesSdk = findFirstElement(libDoc, "/manifest/uses-sdk"); //$NON-NLS-1$ AtomicInteger destValue = new AtomicInteger(1); AtomicInteger srcValue = new AtomicInteger(1); AtomicBoolean destImplied = new AtomicBoolean(true); AtomicBoolean srcImplied = new AtomicBoolean(true); // Check minSdkVersion int destMinSdk = 1; result = extractSdkVersionAttribute( libDoc, destUsesSdk, srcUsesSdk, "min", //$NON-NLS-1$ destValue, srcValue, destImplied, srcImplied); if (result) { // Make it an error for an application to use a library with a greater // minSdkVersion. This means the library code may crash unexpectedly. // TODO it would be nice to be able to work around this in case the // user think s/he knows what s/he's doing. // We could define a simple XML comment flag: <!-- @NoMinSdkVersionMergeError --> destMinSdk = destValue.get(); if (destMinSdk < srcValue.get()) { mLog.conflict(Severity.ERROR, xmlFileAndLine(destUsesSdk == null ? mMainDoc : destUsesSdk), xmlFileAndLine(srcUsesSdk == null ? libDoc : srcUsesSdk), "Main manifest has <uses-sdk android:minSdkVersion='%1$d'> but library uses minSdkVersion='%2$d'%3$s", destMinSdk, srcValue.get(), !destImplied.get() ? "" : //$NON-NLS-1$ "\nNote: main manifest lacks a <uses-sdk android:minSdkVersion> declaration, which defaults to value 1." ); result = false; } } // Check targetSdkVersion. // Note that destValue/srcValue purposely defaults to whatever minSdkVersion was last read // since that's their definition when missing. destImplied.set(true); srcImplied.set(true); boolean result2 = extractSdkVersionAttribute( libDoc, destUsesSdk, srcUsesSdk, "target", //$NON-NLS-1$ destValue, srcValue, destImplied, srcImplied); result &= result2; if (result2) { // Make it a warning for an application to use a library with a greater // targetSdkVersion. int destTargetSdk = destImplied.get() ? destMinSdk : destValue.get(); if (destTargetSdk < srcValue.get()) { mLog.conflict(Severity.WARNING, xmlFileAndLine(destUsesSdk == null ? mMainDoc : destUsesSdk), xmlFileAndLine(srcUsesSdk == null ? libDoc : srcUsesSdk), "Main manifest has <uses-sdk android:targetSdkVersion='%1$d'> but library uses targetSdkVersion='%2$d'%3$s", destTargetSdk, srcValue.get(), !destImplied.get() ? "" : //$NON-NLS-1$ "\nNote: main manifest lacks a <uses-sdk android:targetSdkVersion> declaration, which defaults to value minSdkVersion or 1." ); result = false; } } return result; } /** * Implementation detail for {@link #checkSdkVersion(Document)}. * Note that the various atomic out-variables must be preset to their default before * the call. * <p/> * destValue/srcValue will be filled with the integer value of the field, if present * and a correct number, in which case destImplied/destImplied are also set to true. * Otherwise the values and the implied variables are left untouched. */ private boolean extractSdkVersionAttribute( Document libDoc, Element destUsesSdk, Element srcUsesSdk, String attr, AtomicInteger destValue, AtomicInteger srcValue, AtomicBoolean destImplied, AtomicBoolean srcImplied) { String s = destUsesSdk == null ? "" //$NON-NLS-1$ : destUsesSdk.getAttributeNS(NS_URI, attr + "SdkVersion"); //$NON-NLS-1$ boolean result = true; assert s != null; s = s.trim(); try { if (!s.isEmpty()) { destValue.set(Integer.parseInt(s)); destImplied.set(false); } } catch (NumberFormatException e) { boolean error = true; if (mCallback != null) { // Versions can contain codenames such as "JellyBean". // We'll accept it only if have a callback that can give us the API level for it. int apiLevel = mCallback.queryCodenameApiLevel(s); if (apiLevel > ICallback.UNKNOWN_CODENAME) { destValue.set(apiLevel); destImplied.set(false); error = false; } } if (error) { // Note: NumberFormatException.toString() has no interesting information // so we don't output it. mLog.error(Severity.ERROR, xmlFileAndLine(destUsesSdk == null ? mMainDoc : destUsesSdk), "Failed to parse <uses-sdk %1$sSdkVersion='%2$s'>: must be an integer number or codename.", attr, s); result = false; } } s = srcUsesSdk == null ? "" //$NON-NLS-1$ : srcUsesSdk.getAttributeNS(NS_URI, attr + "SdkVersion"); //$NON-NLS-1$ assert s != null; s = s.trim(); try { if (!s.isEmpty()) { srcValue.set(Integer.parseInt(s)); srcImplied.set(false); } } catch (NumberFormatException e) { boolean error = true; if (mCallback != null) { // Versions can contain codenames such as "JellyBean". // We'll accept it only if have a callback that can give us the API level for it. int apiLevel = mCallback.queryCodenameApiLevel(s); if (apiLevel > ICallback.UNKNOWN_CODENAME) { srcValue.set(apiLevel); srcImplied.set(false); error = false; } } if (error) { mLog.error(Severity.ERROR, xmlFileAndLine(srcUsesSdk == null ? libDoc : srcUsesSdk), "Failed to parse <uses-sdk %1$sSdkVersion='%2$s'>: must be an integer number or codename.", attr, s); result = false; } } return result; } // ----- /** * Given an element E, select which previous siblings we want to merge. * We want to include any whitespace up to the closing of the previous element. * We also want to include up preceding comment nodes and their preceding whitespace. * <p/> * This may returns either {@code end} or a previous sibling. Never returns null. */ @NonNull private Node selectPreviousSiblings(Node end) { Node start = end; Node prev = start.getPreviousSibling(); while (prev != null) { short t = prev.getNodeType(); if (t == Node.TEXT_NODE) { String text = prev.getNodeValue(); if (text == null || !text.trim().isEmpty()) { // Not whitespace, we don't want it. break; } } else if (t == Node.COMMENT_NODE) { // It's a comment. We'll take it. } else { // Not a comment node nor a whitespace text. We don't want it. break; } start = prev; prev = start.getPreviousSibling(); } return start; } /** * Inserts all siblings from {@code start} to {@code end} at the end * of the given destination element. * <p/> * Implementation detail: this clones the source nodes into the destination. * * @param dest The destination at the end of which to insert. Cannot be null. * @param start The first element to insert. Must not be null. * @param end The last element to insert (included). Must not be null. * Must be a direct "next sibling" of the start node. * Can be equal to the start node to insert just that one node. * @return The copy of the {@code end} node in the destination document or null * if no such copy was created and added to the destination. */ private Node insertAtEndOf(Element dest, Node start, Node end) { // Check whether we'll need to adjust URI prefixes String destPrefix = XmlUtils.lookupNamespacePrefix(mMainDoc, NS_URI); String srcPrefix = XmlUtils.lookupNamespacePrefix(start.getOwnerDocument(), NS_URI); boolean needPrefixChange = destPrefix != null && !destPrefix.equals(srcPrefix); // First let's figure out the insertion point. // We want the end of the last 'content' element of the // destination element and basically we want to insert right // before the last whitespace of the destination element. Node target = dest.getLastChild(); while (target != null) { if (target.getNodeType() == Node.TEXT_NODE) { String text = target.getNodeValue(); if (text == null || !text.trim().isEmpty()) { // Not whitespace, insert after. break; } } else { // Not text. Insert after break; } target = target.getPreviousSibling(); } if (target != null) { target = target.getNextSibling(); } // Destination and start..end must not be part of the same document // because we try to import below. If they were, it would mess the // structure. assert dest.getOwnerDocument() == mMainDoc; assert dest.getOwnerDocument() != start.getOwnerDocument(); assert start.getOwnerDocument() == end.getOwnerDocument(); while (start != null) { Node node = mMainDoc.importNode(start, true /*deep*/); if (needPrefixChange) { changePrefix(node, srcPrefix, destPrefix); } if (mInsertSourceMarkers) { // Duplicate source node attribute File file = MergerXmlUtils.getFileFor(start); if (file != null) { MergerXmlUtils.setFileFor(node, file); } } dest.insertBefore(node, target); if (start == end) { return node; } start = start.getNextSibling(); } return null; } /** * Changes the namespace prefix of all nodes, recursively. * * @param node The node to process, as well as all it's descendants. Can be null. * @param srcPrefix The prefix to match. * @param destPrefix The new prefix to replace with. */ private void changePrefix(Node node, String srcPrefix, String destPrefix) { for (; node != null; node = node.getNextSibling()) { if (srcPrefix.equals(node.getPrefix())) { node.setPrefix(destPrefix); } Node child = node.getFirstChild(); if (child != null) { changePrefix(child, srcPrefix, destPrefix); } } } /** * Compares two {@link Element}s recursively. * They must be identical with the same structure. * Order should not matter. * Whitespace and comments are ignored. * * @param expected The first element to compare. * @param actual The second element to compare with. * @param nextSiblings If true, will also compare the following siblings. * If false, it will just compare the given node. * @param diff An optional {@link StringBuilder} where to accumulate a diff output. * @param keyAttr An optional key attribute to always add to elements when dumping a diff. * @return True if {@code e1} and {@code e2} are equal. */ private boolean compareElements( @NonNull Node expected, @NonNull Node actual, boolean nextSiblings, @Nullable StringBuilder diff, @Nullable String keyAttr) { Map<String, String> nsPrefixE = new HashMap<String, String>(); Map<String, String> nsPrefixA = new HashMap<String, String>(); String sE = MergerXmlUtils.printElement(expected, nsPrefixE, ""); //$NON-NLS-1$ String sA = MergerXmlUtils.printElement(actual, nsPrefixA, ""); //$NON-NLS-1$ if (sE.equals(sA)) { return true; } else { if (diff != null) { MergerXmlUtils.printXmlDiff(diff, sE, sA, nsPrefixE, nsPrefixA, NS_URI + ':' + keyAttr); } return false; } } /** * Finds the first element matching the given XPath expression in the given document. * * @param doc The document where to find the expression. * @param path The XPath expression. It must yield an {@link Element} node type. * @return The {@link Element} found or null. */ @Nullable private Element findFirstElement( @NonNull Document doc, @NonNull String path) { Node result; try { result = (Node) mXPath.evaluate(path, doc, XPathConstants.NODE); if (result instanceof Element) { return (Element) result; } if (result != null) { mLog.error(Severity.ERROR, xmlFileAndLine(doc), "Unexpected Node type %s when evaluating %s", //$NON-NLS-1$ result.getClass().getName(), path); } } catch (XPathExpressionException e) { mLog.error(Severity.ERROR, xmlFileAndLine(doc), "XPath error on expr %s: %s", //$NON-NLS-1$ path, e.toString()); } return null; } /** * Finds zero or more elements matching the given XPath expression in the given document. * * @param doc The document where to find the expression. * @param path The XPath expression. Only {@link Element}s nodes will be returned. * @return A list of {@link Element} found, possibly empty but never null. */ private List<Element> findElements( @NonNull Document doc, @NonNull String path) { return findElements(doc, path, null, null); } /** * Finds zero or more elements matching the given XPath expression in the given document. * <p/> * Furthermore, the elements must have an attribute matching the given attribute name * and value if provided. (If you don't need to match an attribute, use the other version.) * <p/> * Note that if you provide {@code attrName} as non-null then the {@code attrValue} * must be non-null too. In this case the XPath expression will be modified to add * the check by naively appending a "[name='value']" filter. * * @param doc The document where to find the expression. * @param path The XPath expression. Only {@link Element}s nodes will be returned. * @param attrName The name of the optional attribute to match. Can be null. * @param attrValue The value of the optional attribute to match. * Can be null if {@code attrName} is null, otherwise must be non-null. * @return A list of {@link Element} found, possibly empty but never null. * * @see #findElements(Document, String) */ private List<Element> findElements( @NonNull Document doc, @NonNull String path, @Nullable String attrName, @Nullable String attrValue) { List<Element> elements = new ArrayList<Element>(); if (attrName != null) { assert attrValue != null; // Generate expression /manifest/application/activity[@android:name='my.fqcn'] path = String.format("%1$s[@%2$s:%3$s='%4$s']", //$NON-NLS-1$ path, NS_PREFIX, attrName, attrValue); } try { NodeList results = (NodeList) mXPath.evaluate(path, doc, XPathConstants.NODESET); if (results != null && results.getLength() > 0) { for (int i = 0; i < results.getLength(); i++) { Node n = results.item(i); assert n instanceof Element; if (n instanceof Element) { elements.add((Element) n); } else { mLog.error(Severity.ERROR, xmlFileAndLine(doc), "Unexpected Node type %s when evaluating %s", //$NON-NLS-1$ n.getClass().getName(), path); } } } } catch (XPathExpressionException e) { mLog.error(Severity.ERROR, xmlFileAndLine(doc), "XPath error on expr %s: %s", //$NON-NLS-1$ path, e.toString()); } return elements; } /** * 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 private FileAndLine xmlFileAndLine(@NonNull Node node) { return MergerXmlUtils.xmlFileAndLine(node); } /** * Checks whether the given element has a tools:merge=override or tools:merge=remove attribute. * @param node The node to check. * @return True if the element has a tools:merge=override or tools:merge=remove attribute. */ private boolean hasOverrideOrRemoveTag(@Nullable Node node) { if (node == null || node.getNodeType() != Node.ELEMENT_NODE) { return false; } NamedNodeMap attrs = node.getAttributes(); Node merge = attrs.getNamedItemNS(TOOLS_URI, MERGE_ATTR); String value = merge == null ? null : merge.getNodeValue(); return MERGE_OVERRIDE.equals(value) || MERGE_REMOVE.equals(value); } /** * Cleans up all tools attributes from the given node hierarchy. * <p/> * If an element is marked with tools:merge=override, this attribute is removed. * If an element is marked with tools:merge=remove, the <em>whole</em> element is removed. * * @param root The root node to parse and edit, recursively. */ private void cleanupToolsAttributes(@Nullable Node root) { if (root == null) { return; } NamedNodeMap attrs = root.getAttributes(); if (attrs != null) { for (int i = attrs.getLength() - 1; i >= 0; i--) { Node attr = attrs.item(i); if (SdkConstants.XMLNS_URI.equals(attr.getNamespaceURI()) && TOOLS_URI.equals(attr.getNodeValue())) { attrs.removeNamedItem(attr.getNodeName()); } else if (TOOLS_URI.equals(attr.getNamespaceURI()) && MERGE_ATTR.equals(attr.getLocalName())) { attrs.removeNamedItem(attr.getNodeName()); } } assert attrs.getNamedItemNS(TOOLS_URI, MERGE_ATTR) == null; } for (Node child = root.getFirstChild(); child != null; ) { if (child.getNodeType() != Node.ELEMENT_NODE) { child = child.getNextSibling(); continue; } attrs = child.getAttributes(); Node merge = attrs == null ? null : attrs.getNamedItemNS(TOOLS_URI, MERGE_ATTR); String value = merge == null ? null : merge.getNodeValue(); Node sibling = child.getNextSibling(); if (MERGE_REMOVE.equals(value)) { // Note: save the previous sibling since removing the child will clear its siblings. Node prev = child.getPreviousSibling(); root.removeChild(child); // If there's some whitespace just before that element, clean it up too. while (prev != null && prev.getNodeType() == Node.TEXT_NODE) { if (prev.getNodeValue().trim().isEmpty()) { Node prevPrev = prev.getPreviousSibling(); root.removeChild(prev); prev = prevPrev; } else { break; } } } else { cleanupToolsAttributes(child); } child = sibling; } } /** * @see #cleanupToolsAttributes(Node) */ private Document cleanupToolsAttributes(@NonNull Document doc) { cleanupToolsAttributes(doc.getFirstChild()); return doc; } /** * Sets whether this manifest merger will insert source markers into the merged source * * @param insertSourceMarkers if true, insert source markers */ public void setInsertSourceMarkers(boolean insertSourceMarkers) { mInsertSourceMarkers = insertSourceMarkers; } /** * Returns whether this manifest merger will insert source markers into the merged source * * @return whether this manifest merger will insert source markers into the merged source */ public boolean isInsertSourceMarkers() { return mInsertSourceMarkers; } /** Inserts source markers in the given document */ private static void insertSourceMarkers(@NonNull Document mainDoc) { Element root = mainDoc.getDocumentElement(); if (root != null) { File file = MergerXmlUtils.getFileFor(root); if (file != null) { insertSourceMarker(mainDoc, root, file, false); } insertSourceMarkers(root, file); } } private static File insertSourceMarkers(@NonNull Node node, @Nullable File currentFile) { for (int i = 0; i < node.getChildNodes().getLength(); i++) { Node child = node.getChildNodes().item(i); short nodeType = child.getNodeType(); if (nodeType == Node.ELEMENT_NODE || nodeType == Node.COMMENT_NODE || nodeType == Node.DOCUMENT_NODE || nodeType == Node.CDATA_SECTION_NODE) { File file = MergerXmlUtils.getFileFor(child); if (file != null && !file.equals(currentFile)) { i += insertSourceMarker(node, child, file, false); currentFile = file; } currentFile = insertSourceMarkers(child, currentFile); } } Node lastElement = node.getLastChild(); while (lastElement != null && lastElement.getNodeType() == Node.TEXT_NODE) { lastElement = lastElement.getPreviousSibling(); } if (lastElement != null && lastElement.getNodeType() == Node.ELEMENT_NODE) { File parentFile = MergerXmlUtils.getFileFor(node); File lastFile = MergerXmlUtils.getFileFor(lastElement); if (lastFile != null && parentFile != null && !parentFile.equals(lastFile)) { insertSourceMarker(node, lastElement, parentFile, true); currentFile = parentFile; } } return currentFile; } private static int insertSourceMarker(@NonNull Node parent, @NonNull Node node, @NonNull File file, boolean after) { int insertCount = 0; Document doc = parent.getNodeType() == Node.DOCUMENT_NODE ? (Document) parent : parent.getOwnerDocument(); String comment; try { comment = SdkUtils.createPathComment(file, true); } catch (MalformedURLException e) { return insertCount; } Node prev = node.getPreviousSibling(); String newline; if (prev != null && prev.getNodeType() == Node.TEXT_NODE) { // Duplicate indentation from previous line. Once we switch the merger // over to using the XmlPrettyPrinter, we won't need this. newline = prev.getNodeValue(); int index = newline.lastIndexOf('\n'); if (index != -1) { newline = newline.substring(index); } } else { newline = "\n"; } if (after) { node = node.getNextSibling(); } parent.insertBefore(doc.createComment(comment), node); insertCount++; // Can't add text nodes at the document level in Xerces, even though // it will happily parse these if (parent.getNodeType() != Node.DOCUMENT_NODE) { parent.insertBefore(doc.createTextNode(newline), node); insertCount++; } return insertCount; } }