blob: 0ad7a7eb9baf26d48224ec638ee6c43e8a04a26d [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_PKG_PREFIX;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_CLASS;
import static com.android.SdkConstants.ATTR_FRAGMENT;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.CONSTRUCTOR_NAME;
import static com.android.SdkConstants.TAG_ACTIVITY;
import static com.android.SdkConstants.TAG_APPLICATION;
import static com.android.SdkConstants.TAG_HEADER;
import static com.android.SdkConstants.TAG_PROVIDER;
import static com.android.SdkConstants.TAG_RECEIVER;
import static com.android.SdkConstants.TAG_SERVICE;
import static com.android.SdkConstants.TAG_STRING;
import static com.android.SdkConstants.VIEW_FRAGMENT;
import static com.android.SdkConstants.VIEW_TAG;
import static com.android.resources.ResourceFolderType.LAYOUT;
import static com.android.resources.ResourceFolderType.VALUES;
import static com.android.resources.ResourceFolderType.XML;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.resources.ResourceFolderType;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.ClassContext;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Detector.ClassScanner;
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.Location;
import com.android.tools.lint.detector.api.Location.Handle;
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.TextFormat;
import com.android.tools.lint.detector.api.XmlContext;
import com.android.utils.SdkUtils;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.MethodNode;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import java.io.File;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Checks to ensure that classes referenced in the manifest actually exist and are included
*
*/
public class MissingClassDetector extends LayoutDetector implements ClassScanner {
/** Manifest-referenced classes missing from the project or libraries */
public static final Issue MISSING = Issue.create(
"MissingRegistered", //$NON-NLS-1$
"Missing registered class",
"If a class is referenced in the manifest, it must also exist in the project (or in one " +
"of the libraries included by the project. This check helps uncover typos in " +
"registration names, or attempts to rename or move classes without updating the " +
"manifest file properly.",
Category.CORRECTNESS,
8,
Severity.ERROR,
new Implementation(
MissingClassDetector.class,
EnumSet.of(Scope.MANIFEST, Scope.CLASS_FILE,
Scope.JAVA_LIBRARIES, Scope.RESOURCE_FILE)))
.addMoreInfo("http://developer.android.com/guide/topics/manifest/manifest-intro.html"); //$NON-NLS-1$
/** Are activity, service, receiver etc subclasses instantiatable? */
public static final Issue INSTANTIATABLE = Issue.create(
"Instantiatable", //$NON-NLS-1$
"Registered class is not instantiatable",
"Activities, services, broadcast receivers etc. registered in the manifest file " +
"must be \"instantiatable\" by the system, which means that the class must be " +
"public, it must have an empty public constructor, and if it's an inner class, " +
"it must be a static inner class.",
Category.CORRECTNESS,
6,
Severity.FATAL,
new Implementation(
MissingClassDetector.class,
Scope.CLASS_FILE_SCOPE));
/** Is the right character used for inner class separators? */
public static final Issue INNERCLASS = Issue.create(
"InnerclassSeparator", //$NON-NLS-1$
"Inner classes should use `$` rather than `.`",
"When you reference an inner class in a manifest file, you must use '$' instead of '.' " +
"as the separator character, i.e. Outer$Inner instead of Outer.Inner.\n" +
"\n" +
"(If you get this warning for a class which is not actually an inner class, it's " +
"because you are using uppercase characters in your package name, which is not " +
"conventional.)",
Category.CORRECTNESS,
3,
Severity.WARNING,
new Implementation(
MissingClassDetector.class,
Scope.MANIFEST_SCOPE));
private Map<String, Location.Handle> mReferencedClasses;
private Set<String> mCustomViews;
private boolean mHaveClasses;
/** Constructs a new {@link MissingClassDetector} */
public MissingClassDetector() {
}
@NonNull
@Override
public Speed getSpeed() {
return Speed.FAST;
}
// ---- Implements XmlScanner ----
@Override
public Collection<String> getApplicableElements() {
return ALL;
}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == VALUES || folderType == LAYOUT || folderType == XML;
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
String pkg = null;
Node classNameNode;
String className;
String tag = element.getTagName();
ResourceFolderType folderType = context.getResourceFolderType();
if (folderType == VALUES) {
if (!tag.equals(TAG_STRING)) {
return;
}
Attr attr = element.getAttributeNode(ATTR_NAME);
if (attr == null) {
return;
}
className = attr.getValue();
classNameNode = attr;
} else if (folderType == LAYOUT) {
if (tag.indexOf('.') > 0) {
className = tag;
classNameNode = element;
} else if (tag.equals(VIEW_FRAGMENT) || tag.equals(VIEW_TAG)) {
Attr attr = element.getAttributeNodeNS(ANDROID_URI, ATTR_NAME);
if (attr == null) {
attr = element.getAttributeNode(ATTR_CLASS);
}
if (attr == null) {
return;
}
className = attr.getValue();
classNameNode = attr;
} else {
return;
}
} else if (folderType == XML) {
if (!tag.equals(TAG_HEADER)) {
return;
}
Attr attr = element.getAttributeNodeNS(ANDROID_URI, ATTR_FRAGMENT);
if (attr == null) {
return;
}
className = attr.getValue();
classNameNode = attr;
} else {
// Manifest file
if (TAG_APPLICATION.equals(tag)
|| TAG_ACTIVITY.equals(tag)
|| TAG_SERVICE.equals(tag)
|| TAG_RECEIVER.equals(tag)
|| TAG_PROVIDER.equals(tag)) {
Attr attr = element.getAttributeNodeNS(ANDROID_URI, ATTR_NAME);
if (attr == null) {
return;
}
className = attr.getValue();
classNameNode = attr;
pkg = context.getMainProject().getPackage();
} else {
return;
}
}
if (className.isEmpty()) {
return;
}
String fqcn;
int dotIndex = className.indexOf('.');
if (dotIndex <= 0) {
if (pkg == null) {
return; // value file
}
if (dotIndex == 0) {
fqcn = pkg + className;
} else {
// According to the <activity> manifest element documentation, this is not
// valid ( http://developer.android.com/guide/topics/manifest/activity-element.html )
// but it appears in manifest files and appears to be supported by the runtime
// so handle this in code as well:
fqcn = pkg + '.' + className;
}
} else { // else: the class name is already a fully qualified class name
fqcn = className;
// Only look for fully qualified tracker names in analytics files
if (folderType == VALUES
&& !SdkUtils.endsWith(context.file.getPath(), "analytics.xml")) { //$NON-NLS-1$
return;
}
}
String signature = ClassContext.getInternalName(fqcn);
if (signature.isEmpty() || signature.startsWith(ANDROID_PKG_PREFIX)) {
return;
}
if (!context.getProject().getReportIssues()) {
// If this is a library project not being analyzed, ignore it
return;
}
Handle handle = null;
if (!context.getDriver().isSuppressed(context, MISSING, element)) {
if (mReferencedClasses == null) {
mReferencedClasses = Maps.newHashMapWithExpectedSize(16);
mCustomViews = Sets.newHashSetWithExpectedSize(8);
}
handle = context.createLocationHandle(element);
mReferencedClasses.put(signature, handle);
if (folderType == LAYOUT && !tag.equals(VIEW_FRAGMENT)) {
mCustomViews.add(ClassContext.getInternalName(className));
}
}
if (signature.indexOf('$') != -1) {
checkInnerClass(context, element, pkg, classNameNode, className);
// The internal name contains a $ which means it's an inner class.
// The conversion from fqcn to internal name is a bit ambiguous:
// "a.b.C.D" usually means "inner class D in class C in package a.b".
// However, it can (see issue 31592) also mean class D in package "a.b.C".
// To make sure we don't falsely complain that foo/Bar$Baz doesn't exist,
// in case the user has actually created a package named foo/Bar and a proper
// class named Baz, we register *both* into the reference map.
// When generating errors we'll look for these an rip them back out if
// it looks like one of the two variations have been seen.
if (handle != null) {
// Assume that each successive $ is really a capitalized package name
// instead. In other words, for A$B$C$D (assumed to be class A with
// inner classes A.B, A.B.C and A.B.C.D) generate the following possible
// referenced classes A/B$C$D (class B in package A with inner classes C and C.D),
// A/B/C$D and A/B/C/D
while (true) {
int index = signature.indexOf('$');
if (index == -1) {
break;
}
signature = signature.substring(0, index) + '/'
+ signature.substring(index + 1);
mReferencedClasses.put(signature, handle);
if (folderType == LAYOUT && !tag.equals(VIEW_FRAGMENT)) {
mCustomViews.add(signature);
}
}
}
}
}
private static void checkInnerClass(XmlContext context, Element element, String pkg,
Node classNameNode, String className) {
if (pkg != null && className.indexOf('$') == -1 && className.indexOf('.', 1) > 0) {
boolean haveUpperCase = false;
for (int i = 0, n = pkg.length(); i < n; i++) {
if (Character.isUpperCase(pkg.charAt(i))) {
haveUpperCase = true;
break;
}
}
if (!haveUpperCase) {
String fixed = className.charAt(0) + className.substring(1).replace('.','$');
String message = "Use '$' instead of '.' for inner classes " +
"(or use only lowercase letters in package names); replace \"" +
className + "\" with \"" + fixed + "\"";
Location location = context.getLocation(classNameNode);
context.report(INNERCLASS, element, location, message);
}
}
}
@Override
public void afterCheckProject(@NonNull Context context) {
if (!context.getProject().isLibrary() && mHaveClasses
&& mReferencedClasses != null && !mReferencedClasses.isEmpty()
&& context.getDriver().getScope().contains(Scope.CLASS_FILE)) {
List<String> classes = new ArrayList<String>(mReferencedClasses.keySet());
Collections.sort(classes);
for (String owner : classes) {
Location.Handle handle = mReferencedClasses.get(owner);
String fqcn = ClassContext.getFqcn(owner);
String signature = ClassContext.getInternalName(fqcn);
if (!signature.equals(owner)) {
if (!mReferencedClasses.containsKey(signature)) {
continue;
}
} else if (signature.indexOf('$') != -1) {
signature = signature.replace('$', '/');
if (!mReferencedClasses.containsKey(signature)) {
continue;
}
}
mReferencedClasses.remove(owner);
// Ignore usages of platform libraries
if (owner.startsWith("android/")) { //$NON-NLS-1$
continue;
}
String message = String.format(
"Class referenced in the manifest, `%1$s`, was not found in the " +
"project or the libraries", fqcn);
Location location = handle.resolve();
File parentFile = location.getFile().getParentFile();
if (parentFile != null) {
String parent = parentFile.getName();
ResourceFolderType type = ResourceFolderType.getFolderType(parent);
if (type == LAYOUT) {
message = String.format(
"Class referenced in the layout file, `%1$s`, was not found in "
+ "the project or the libraries", fqcn);
} else if (type == XML) {
message = String.format(
"Class referenced in the preference header file, `%1$s`, was not "
+ "found in the project or the libraries", fqcn);
} else if (type == VALUES) {
message = String.format(
"Class referenced in the analytics file, `%1$s`, was not "
+ "found in the project or the libraries", fqcn);
}
}
context.report(MISSING, location, message);
}
}
}
// ---- Implements ClassScanner ----
@Override
public void checkClass(@NonNull ClassContext context, @NonNull ClassNode classNode) {
if (!mHaveClasses && !context.isFromClassLibrary()
&& context.getProject() == context.getMainProject()) {
mHaveClasses = true;
}
String curr = classNode.name;
if (mReferencedClasses != null && mReferencedClasses.containsKey(curr)) {
boolean isCustomView = mCustomViews.contains(curr);
removeReferences(curr);
// Ensure that the class is public, non static and has a null constructor!
if ((classNode.access & Opcodes.ACC_PUBLIC) == 0) {
context.report(INSTANTIATABLE, context.getLocation(classNode), String.format(
"This class should be public (%1$s)",
ClassContext.createSignature(classNode.name, null, null)));
return;
}
if (classNode.name.indexOf('$') != -1 && !LintUtils.isStaticInnerClass(classNode)) {
context.report(INSTANTIATABLE, context.getLocation(classNode), String.format(
"This inner class should be static (%1$s)",
ClassContext.createSignature(classNode.name, null, null)));
return;
}
boolean hasDefaultConstructor = false;
@SuppressWarnings("rawtypes") // ASM API
List methodList = classNode.methods;
for (Object m : methodList) {
MethodNode method = (MethodNode) m;
if (method.name.equals(CONSTRUCTOR_NAME)) {
if (method.desc.equals("()V")) { //$NON-NLS-1$
// The constructor must be public
if ((method.access & Opcodes.ACC_PUBLIC) != 0) {
hasDefaultConstructor = true;
} else {
context.report(INSTANTIATABLE, context.getLocation(method, classNode),
"The default constructor must be public");
// Also mark that we have a constructor so we don't complain again
// below since we've already emitted a more specific error related
// to the default constructor
hasDefaultConstructor = true;
}
}
}
}
if (!hasDefaultConstructor && !isCustomView && !context.isFromClassLibrary()
&& context.getProject().getReportIssues()) {
context.report(INSTANTIATABLE, context.getLocation(classNode), String.format(
"This class should provide a default constructor (a public " +
"constructor with no arguments) (%1$s)",
ClassContext.createSignature(classNode.name, null, null)));
}
}
}
private void removeReferences(String curr) {
mReferencedClasses.remove(curr);
// Since "A.B.C" is ambiguous whether it's referencing a class in package A.B or
// an inner class C in package A, we insert multiple possible references when we
// encounter the A.B.C reference; now that we've seen the actual class we need to
// remove all the possible permutations we've added such that the permutations
// don't count as unreferenced classes.
int index = curr.lastIndexOf('/');
if (index == -1) {
return;
}
boolean hasCapitalizedPackageName = false;
for (int i = index - 1; i >= 0; i--) {
char c = curr.charAt(i);
if (Character.isUpperCase(c)) {
hasCapitalizedPackageName = true;
break;
}
}
if (!hasCapitalizedPackageName) {
// No path ambiguity
return;
}
while (true) {
index = curr.lastIndexOf('/');
if (index == -1) {
break;
}
curr = curr.substring(0, index) + '$' + curr.substring(index + 1);
mReferencedClasses.remove(curr);
}
}
/**
* Given an error message produced by this lint detector for the given issue type,
* returns the old value to be replaced in the source code.
* <p>
* Intended for IDE quickfix implementations.
*
* @param issue the corresponding issue
* @param errorMessage the error message associated with the error
* @param format the format of the error message
* @return the corresponding old value, or null if not recognized
*/
@Nullable
public static String getOldValue(@NonNull Issue issue, @NonNull String errorMessage,
@NonNull TextFormat format) {
if (issue == INNERCLASS) {
errorMessage = format.toText(errorMessage);
return LintUtils.findSubstring(errorMessage, " replace \"", "\"");
}
return null;
}
/**
* Given an error message produced by this lint detector for the given issue type,
* returns the new value to be put into the source code.
* <p>
* Intended for IDE quickfix implementations.
*
* @param issue the corresponding issue
* @param errorMessage the error message associated with the error
* @param format the format of the error message
* @return the corresponding new value, or null if not recognized
*/
@Nullable
public static String getNewValue(@NonNull Issue issue, @NonNull String errorMessage,
@NonNull TextFormat format) {
if (issue == INNERCLASS) {
errorMessage = format.toText(errorMessage);
return LintUtils.findSubstring(errorMessage, " with \"", "\"");
}
return null;
}
}