blob: 50bfc21bb32145a14d3baa360a17b07eaeeccff4 [file] [log] [blame]
/*
* 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.tools.lint.checks;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.APP_PREFIX;
import static com.android.SdkConstants.AUTO_URI;
import static com.android.SdkConstants.TOOLS_PREFIX;
import static com.android.SdkConstants.TOOLS_URI;
import static com.android.SdkConstants.URI_PREFIX;
import static com.android.SdkConstants.XMLNS_PREFIX;
import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.LayoutDetector;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.Speed;
import com.android.tools.lint.detector.api.XmlContext;
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.util.HashMap;
import java.util.Map;
/**
* Checks for various issues related to XML namespaces
*/
public class NamespaceDetector extends LayoutDetector {
@SuppressWarnings("unchecked")
private static final Implementation IMPLEMENTATION = new Implementation(
NamespaceDetector.class,
Scope.MANIFEST_AND_RESOURCE_SCOPE,
Scope.RESOURCE_FILE_SCOPE, Scope.MANIFEST_SCOPE);
/** Typos in the namespace */
public static final Issue TYPO = Issue.create(
"NamespaceTypo", //$NON-NLS-1$
"Misspelled namespace declaration",
"Accidental misspellings in namespace declarations can lead to some very " +
"obscure error messages. This check looks for potential misspellings to " +
"help track these down.",
Category.CORRECTNESS,
8,
Severity.FATAL,
IMPLEMENTATION);
/** Unused namespace declarations */
public static final Issue UNUSED = Issue.create(
"UnusedNamespace", //$NON-NLS-1$
"Unused namespace",
"Unused namespace declarations take up space and require processing that is not " +
"necessary",
Category.PERFORMANCE,
1,
Severity.WARNING,
IMPLEMENTATION);
/** Using custom namespace attributes in a library project */
public static final Issue CUSTOM_VIEW = Issue.create(
"LibraryCustomView", //$NON-NLS-1$
"Custom views in libraries should use res-auto-namespace",
"When using a custom view with custom attributes in a library project, the layout " +
"must use the special namespace " + AUTO_URI + " instead of a URI which includes " +
"the library project's own package. This will be used to automatically adjust the " +
"namespace of the attributes when the library resources are merged into the " +
"application project.",
Category.CORRECTNESS,
6,
Severity.FATAL,
IMPLEMENTATION);
/** Unused namespace declarations */
public static final Issue RES_AUTO = Issue.create(
"ResAuto", //$NON-NLS-1$
"Hardcoded Package in Namespace",
"In Gradle projects, the actual package used in the final APK can vary; for example," +
"you can add a `.debug` package suffix in one version and not the other. " +
"Therefore, you should *not* hardcode the application package in the resource; " +
"instead, use the special namespace `http://schemas.android.com/apk/res-auto` " +
"which will cause the tools to figure out the right namespace for the resource " +
"regardless of the actual package used during the build.",
Category.CORRECTNESS,
9,
Severity.FATAL,
IMPLEMENTATION);
/** Prefix relevant for custom namespaces */
private static final String XMLNS_ANDROID = "xmlns:android"; //$NON-NLS-1$
private static final String XMLNS_A = "xmlns:a"; //$NON-NLS-1$
private Map<String, Attr> mUnusedNamespaces;
private boolean mCheckUnused;
/** Constructs a new {@link NamespaceDetector} */
public NamespaceDetector() {
}
@NonNull
@Override
public Speed getSpeed() {
return Speed.FAST;
}
@Override
public void visitDocument(@NonNull XmlContext context, @NonNull Document document) {
boolean haveCustomNamespace = false;
Element root = document.getDocumentElement();
NamedNodeMap attributes = root.getAttributes();
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Node item = attributes.item(i);
String prefix = item.getNodeName();
if (prefix.startsWith(XMLNS_PREFIX)) {
String value = item.getNodeValue();
if (!value.equals(ANDROID_URI)) {
Attr attribute = (Attr) item;
if (value.startsWith(URI_PREFIX)) {
haveCustomNamespace = true;
if (mUnusedNamespaces == null) {
mUnusedNamespaces = new HashMap<String, Attr>();
}
mUnusedNamespaces.put(prefix.substring(XMLNS_PREFIX.length()),
attribute);
} else if (value.startsWith("urn:")) { //$NON-NLS-1$
continue;
} else if (!value.startsWith("http://")) { //$NON-NLS-1$
if (context.isEnabled(TYPO)) {
context.report(TYPO, attribute, context.getValueLocation(attribute),
"Suspicious namespace: should start with `http://`");
}
continue;
} else if (!value.equals(AUTO_URI) && value.contains("auto") && //$NON-NLS-1$
value.startsWith("http://schemas.android.com/")) { //$NON-NLS-1$
context.report(RES_AUTO, attribute, context.getValueLocation(attribute),
"Suspicious namespace: Did you mean `" + AUTO_URI + "`?");
} else if (value.equals(TOOLS_URI) && (prefix.equals(XMLNS_ANDROID) ||
prefix.endsWith(APP_PREFIX) && prefix.equals(
XMLNS_PREFIX + APP_PREFIX))) {
context.report(TYPO, attribute, context.getValueLocation(attribute),
"Suspicious namespace and prefix combination");
}
if (!context.isEnabled(TYPO)) {
continue;
}
String name = attribute.getName();
if (!name.equals(XMLNS_ANDROID) && !name.equals(XMLNS_A)) {
// See if it looks like a typo
int resIndex = value.indexOf("/res/"); //$NON-NLS-1$
if (resIndex != -1 && value.length() + 5 > URI_PREFIX.length()) {
String urlPrefix = value.substring(0, resIndex + 5);
if (!urlPrefix.equals(URI_PREFIX) &&
LintUtils.editDistance(URI_PREFIX, urlPrefix) <= 3) {
String correctUri = URI_PREFIX + value.substring(resIndex + 5);
context.report(TYPO, attribute,
context.getValueLocation(attribute),
String.format(
"Possible typo in URL: was `\"%1$s\"`, should " +
"probably be `\"%2$s\"`",
value, correctUri));
}
}
continue;
}
if (name.equals(XMLNS_A)) {
// For the "android" prefix we always assume that the namespace prefix
// should be our expected prefix, but for the "a" prefix we make sure
// that it's at least "close"; if you're bound it to something completely
// different, don't complain.
if (LintUtils.editDistance(ANDROID_URI, value) > 4) {
continue;
}
}
if (value.equalsIgnoreCase(ANDROID_URI)) {
context.report(TYPO, attribute, context.getValueLocation(attribute),
String.format(
"URI is case sensitive: was `\"%1$s\"`, expected `\"%2$s\"`",
value, ANDROID_URI));
} else {
context.report(TYPO, attribute, context.getValueLocation(attribute),
String.format(
"Unexpected namespace URI bound to the `\"android\"` " +
"prefix, was `%1$s`, expected `%2$s`", value, ANDROID_URI));
}
} else if (!prefix.equals(XMLNS_ANDROID) &&
((prefix.endsWith(TOOLS_PREFIX) && prefix.equals(XMLNS_PREFIX + TOOLS_PREFIX)) ||
(prefix.endsWith(APP_PREFIX) && prefix.equals(XMLNS_PREFIX + APP_PREFIX)))) {
Attr attribute = (Attr) item;
context.report(TYPO, attribute, context.getValueLocation(attribute),
"Suspicious namespace and prefix combination");
}
}
}
if (haveCustomNamespace) {
Project project = context.getProject();
boolean checkCustomAttrs =
context.isEnabled(CUSTOM_VIEW) && project.isLibrary()
|| context.isEnabled(RES_AUTO) && project.isGradleProject();
mCheckUnused = context.isEnabled(UNUSED);
if (checkCustomAttrs) {
checkCustomNamespace(context, root);
}
checkElement(root);
if (mCheckUnused && !mUnusedNamespaces.isEmpty()) {
for (Map.Entry<String, Attr> entry : mUnusedNamespaces.entrySet()) {
String prefix = entry.getKey();
Attr attribute = entry.getValue();
context.report(UNUSED, attribute, context.getLocation(attribute),
String.format("Unused namespace `%1$s`", prefix));
}
}
}
}
private static void checkCustomNamespace(XmlContext context, Element element) {
NamedNodeMap attributes = element.getAttributes();
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Attr attribute = (Attr) attributes.item(i);
if (attribute.getName().startsWith(XMLNS_PREFIX)) {
String uri = attribute.getValue();
if (uri != null && !uri.isEmpty() && uri.startsWith(URI_PREFIX)
&& !uri.equals(ANDROID_URI)) {
if (context.getProject().isGradleProject()) {
context.report(RES_AUTO, attribute, context.getValueLocation(attribute),
"In Gradle projects, always use `" + AUTO_URI + "` for custom " +
"attributes");
} else {
context.report(CUSTOM_VIEW, attribute, context.getValueLocation(attribute),
"When using a custom namespace attribute in a library project, " +
"use the namespace `\"" + AUTO_URI + "\"` instead.");
}
}
}
}
}
private void checkElement(Node node) {
if (node.getNodeType() == Node.ELEMENT_NODE) {
if (mCheckUnused) {
NamedNodeMap attributes = node.getAttributes();
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Attr attribute = (Attr) attributes.item(i);
String prefix = attribute.getPrefix();
if (prefix != null) {
mUnusedNamespaces.remove(prefix);
}
}
}
NodeList childNodes = node.getChildNodes();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
checkElement(childNodes.item(i));
}
}
}
}