/*
* 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_PREFIX;
import static com.android.SdkConstants.ANDROID_THEME_PREFIX;
import static com.android.SdkConstants.ATTR_CLASS;
import static com.android.SdkConstants.CONSTRUCTOR_NAME;
import static com.android.SdkConstants.TARGET_API;
import static com.android.SdkConstants.VIEW_TAG;
import static com.android.tools.lint.detector.api.LintUtils.getNextInstruction;
import static com.android.tools.lint.detector.api.Location.SearchDirection.BACKWARD;
import static com.android.tools.lint.detector.api.Location.SearchDirection.FORWARD;
import static com.android.tools.lint.detector.api.Location.SearchDirection.NEAREST;
import com.android.annotations.NonNull;
import com.android.resources.ResourceFolderType;
import com.android.tools.lint.client.api.LintDriver;
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;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Location.SearchHints;
import com.android.tools.lint.detector.api.ResourceXmlDetector;
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.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.AnnotationNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FieldInsnNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.IntInsnNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.LocalVariableNode;
import org.objectweb.asm.tree.LookupSwitchInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
/**
* Looks for usages of APIs that are not supported in all the versions targeted
* by this application (according to its minimum API requirement in the manifest).
*/
public class ApiDetector extends ResourceXmlDetector implements Detector.ClassScanner {
private static final boolean AOSP_BUILD = System.getenv("ANDROID_BUILD_TOP") != null; //$NON-NLS-1$
/** Accessing an unsupported API */
public static final Issue UNSUPPORTED = Issue.create("NewApi", //$NON-NLS-1$
"Finds API accesses to APIs that are not supported in all targeted API versions",
"This check scans through all the Android API calls in the application and " +
"warns about any calls that are not available on *all* versions targeted " +
"by this application (according to its minimum SDK attribute in the manifest).\n" +
"\n" +
"If you really want to use this API and don't need to support older devices just " +
"set the `minSdkVersion` in your `AndroidManifest.xml` file." +
"\n" +
"If your code is *deliberately* accessing newer APIs, and you have ensured " +
"(e.g. with conditional execution) that this code will only ever be called on a " +
"supported platform, then you can annotate your class or method with the " +
"`@TargetApi` annotation specifying the local minimum SDK to apply, such as " +
"`@TargetApi(11)`, such that this check considers 11 rather than your manifest " +
"file's minimum SDK as the required API level.",
Category.CORRECTNESS,
6,
Severity.ERROR,
ApiDetector.class,
EnumSet.of(Scope.CLASS_FILE, Scope.RESOURCE_FILE, Scope.MANIFEST))
.addAnalysisScope(Scope.RESOURCE_FILE_SCOPE)
.addAnalysisScope(Scope.CLASS_FILE_SCOPE);
private static final String TARGET_API_VMSIG = '/' + TARGET_API + ';';
private static final String SWITCH_TABLE_PREFIX = "$SWITCH_TABLE$"; //$NON-NLS-1$
private static final String ORDINAL_METHOD = "ordinal"; //$NON-NLS-1$
private ApiLookup mApiDatabase;
private int mMinApi = -1;
/** Constructs a new API check */
public ApiDetector() {
}
@Override
public @NonNull Speed getSpeed() {
return Speed.SLOW;
}
@Override
public void beforeCheckProject(@NonNull Context context) {
mApiDatabase = ApiLookup.get(context.getClient());
// We can't look up the minimum API required by the project here:
// The manifest file hasn't been processed yet in the -before- project hook.
// For now it's initialized lazily in getMinSdk(Context), but the
// lint infrastructure should be fixed to parse manifest file up front.
}
// ---- Implements XmlScanner ----
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return true;
}
@Override
public Collection<String> getApplicableElements() {
return ALL;
}
@Override
public Collection<String> getApplicableAttributes() {
return ALL;
}
@Override
public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) {
if (mApiDatabase == null) {
return;
}
String value = attribute.getValue();
String prefix = null;
if (value.startsWith(ANDROID_PREFIX)) {
prefix = ANDROID_PREFIX;
} else if (value.startsWith(ANDROID_THEME_PREFIX)) {
prefix = ANDROID_THEME_PREFIX;
} else {
return;
}
assert prefix != null;
// Convert @android:type/foo into android/R$type and "foo"
int index = value.indexOf('/', prefix.length());
if (index != -1) {
String owner = "android/R$" //$NON-NLS-1$
+ value.substring(prefix.length(), index);
String name = value.substring(index + 1);
if (name.indexOf('.') != -1) {
name = name.replace('.', '_');
}
int api = mApiDatabase.getFieldVersion(owner, name);
int minSdk = getMinSdk(context);
if (api > minSdk && api > context.getFolderVersion()) {
Location location = context.getLocation(attribute);
String message = String.format(
"%1$s requires API level %2$d (current min is %3$d)",
value, api, minSdk);
context.report(UNSUPPORTED, attribute, location, message, null);
}
}
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
if (mApiDatabase == null) {
return;
}
String tag = element.getTagName();
ResourceFolderType folderType = context.getResourceFolderType();
if (folderType != ResourceFolderType.LAYOUT) {
if (element.getParentNode().getNodeType() != Node.ELEMENT_NODE) {
// Root node
return;
}
NodeList childNodes = element.getChildNodes();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
Node textNode = childNodes.item(i);
if (textNode.getNodeType() == Node.TEXT_NODE) {
String text = textNode.getNodeValue();
if (text.indexOf(ANDROID_PREFIX) != -1) {
text = text.trim();
// Convert @android:type/foo into android/R$type and "foo"
int index = text.indexOf('/', ANDROID_PREFIX.length());
if (index != -1) {
String owner = "android/R$" //$NON-NLS-1$
+ text.substring(ANDROID_PREFIX.length(), index);
String name = text.substring(index + 1);
if (name.indexOf('.') != -1) {
name = name.replace('.', '_');
}
int api = mApiDatabase.getFieldVersion(owner, name);
int minSdk = getMinSdk(context);
if (api > minSdk && api > context.getFolderVersion()) {
Location location = context.getLocation(textNode);
String message = String.format(
"%1$s requires API level %2$d (current min is %3$d)",
text, api, minSdk);
context.report(UNSUPPORTED, element, location, message, null);
}
}
}
}
}
} else if (folderType == ResourceFolderType.LAYOUT) {
if (VIEW_TAG.equals(tag)) {
tag = element.getAttribute(ATTR_CLASS);
if (tag == null || tag.isEmpty()) {
return;
}
}
// Check widgets to make sure they're available in this version of the SDK.
if (tag.indexOf('.') != -1 ||
folderType != ResourceFolderType.LAYOUT) {
// Custom views aren't in the index
return;
}
// TODO: Consider other widgets outside of android.widget.*
int api = mApiDatabase.getCallVersion("android/widget/" + tag, //$NON-NLS-1$
CONSTRUCTOR_NAME,
// Not all views provided this constructor right away, for example,
// LinearLayout added it in API 11 yet LinearLayout is much older:
// "(Landroid/content/Context;Landroid/util/AttributeSet;I)V"); //$NON-NLS-1$
"(Landroid/content/Context;)"); //$NON-NLS-1$
int minSdk = getMinSdk(context);
if (api > minSdk && api > context.getFolderVersion()) {
Location location = context.getLocation(element);
String message = String.format(
"View requires API level %1$d (current min is %2$d): <%3$s>",
api, minSdk, tag);
context.report(UNSUPPORTED, element, location, message, null);
}
}
}
private int getMinSdk(Context context) {
if (mMinApi == -1) {
mMinApi = context.getMainProject().getMinSdk();
}
return mMinApi;
}
// ---- Implements ClassScanner ----
@SuppressWarnings("rawtypes") // ASM API
@Override
public void checkClass(final @NonNull ClassContext context, @NonNull ClassNode classNode) {
if (mApiDatabase == null) {
return;
}
if (AOSP_BUILD && classNode.name.startsWith("android/support/")) { //$NON-NLS-1$
return;
}
// Requires util package (add prebuilts/tools/common/asm-tools/asm-debug-all-4.0.jar)
//classNode.accept(new TraceClassVisitor(new PrintWriter(System.out)));
int classMinSdk = getClassMinSdk(context, classNode);
if (classMinSdk == -1) {
classMinSdk = getMinSdk(context);
}
List methodList = classNode.methods;
for (Object m : methodList) {
MethodNode method = (MethodNode) m;
int minSdk = getLocalMinSdk(method.invisibleAnnotations);
if (minSdk == -1) {
minSdk = classMinSdk;
}
InsnList nodes = method.instructions;
// Check types in parameter list and types of local variables
List localVariables = method.localVariables;
if (localVariables != null) {
for (Object v : localVariables) {
LocalVariableNode var = (LocalVariableNode) v;
String desc = var.desc;
if (desc.charAt(0) == 'L') {
// "Lpackage/Class;" => "package/Bar"
String className = desc.substring(1, desc.length() - 1);
int api = mApiDatabase.getClassVersion(className);
if (api > minSdk) {
String fqcn = ClassContext.getFqcn(className);
String message = String.format(
"Class requires API level %1$d (current min is %2$d): %3$s",
api, minSdk, fqcn);
report(context, message, var.start, method,
className.substring(className.lastIndexOf('/') + 1), null,
SearchHints.create(NEAREST).matchJavaSymbol());
}
}
}
}
// Check return type
// The parameter types are already handled as local variables so we can skip
// right to the return type.
// Check types in parameter list
String signature = method.desc;
if (signature != null) {
int args = signature.indexOf(')');
if (args != -1 && signature.charAt(args + 1) == 'L') {
String type = signature.substring(args + 2, signature.length() - 1);
int api = mApiDatabase.getClassVersion(type);
if (api > minSdk) {
String fqcn = ClassContext.getFqcn(type);
String message = String.format(
"Class requires API level %1$d (current min is %2$d): %3$s",
api, minSdk, fqcn);
AbstractInsnNode first = nodes.size() > 0 ? nodes.get(0) : null;
report(context, message, first, method, method.name, null,
SearchHints.create(BACKWARD).matchJavaSymbol());
}
}
}
for (int i = 0, n = nodes.size(); i < n; i++) {
AbstractInsnNode instruction = nodes.get(i);
int type = instruction.getType();
if (type == AbstractInsnNode.METHOD_INSN) {
MethodInsnNode node = (MethodInsnNode) instruction;
String name = node.name;
String owner = node.owner;
String desc = node.desc;
// No need to check methods in this local class; we know they
// won't be an API match
if (node.getOpcode() == Opcodes.INVOKEVIRTUAL
&& owner.equals(classNode.name)) {
owner = classNode.superName;
}
boolean checkingSuperClass = false;
while (owner != null) {
int api = mApiDatabase.getCallVersion(owner, name, desc);
if (api > minSdk) {
if (method.name.startsWith(SWITCH_TABLE_PREFIX)) {
// We're in a compiler-generated method to generate an
// array indexed by enum ordinal values to enum values. The enum
// itself must be requiring a higher API number than is
// currently used, but the call site for the switch statement
// will also be referencing it, so no need to report these
// calls.
break;
}
if (!checkingSuperClass
&& node.getOpcode() == Opcodes.INVOKEVIRTUAL
&& methodDefinedLocally(classNode, name, desc)) {
break;
}
String fqcn;
if (CONSTRUCTOR_NAME.equals(name)) {
fqcn = "new " + ClassContext.getFqcn(owner); //$NON-NLS-1$
} else {
fqcn = ClassContext.getFqcn(owner) + '#' + name;
}
String message = String.format(
"Call requires API level %1$d (current min is %2$d): %3$s",
api, minSdk, fqcn);
if (name.equals(ORDINAL_METHOD)
&& instruction.getNext() != null
&& instruction.getNext().getNext() != null
&& instruction.getNext().getOpcode() == Opcodes.IALOAD
&& instruction.getNext().getNext().getOpcode()
== Opcodes.TABLESWITCH) {
message = String.format(
"Enum for switch requires API level %1$d " +
"(current min is %2$d): %3$s",
api, minSdk, ClassContext.getFqcn(owner));
}
report(context, message, node, method, name, null,
SearchHints.create(FORWARD).matchJavaSymbol());
}
// For virtual dispatch, walk up the inheritance chain checking
// each inherited method
if (owner.startsWith("android/") //$NON-NLS-1$
|| owner.startsWith("java/") //$NON-NLS-1$
|| owner.startsWith("javax/")) { //$NON-NLS-1$
// The API map has already inlined all inherited methods
// so no need to keep checking up the chain
owner = null;
} else if (node.getOpcode() == Opcodes.INVOKEVIRTUAL) {
owner = context.getDriver().getSuperClass(owner);
} else if (node.getOpcode() == Opcodes.INVOKESTATIC && api == -1) {
// Inherit through static classes as well
owner = context.getDriver().getSuperClass(owner);
} else {
owner = null;
}
checkingSuperClass = true;
}
} else if (type == AbstractInsnNode.FIELD_INSN) {
FieldInsnNode node = (FieldInsnNode) instruction;
String name = node.name;
String owner = node.owner;
int api = mApiDatabase.getFieldVersion(owner, name);
if (api > minSdk) {
if (method.name.startsWith(SWITCH_TABLE_PREFIX)) {
checkSwitchBlock(context, classNode, node, method, name, owner,
api, minSdk);
continue;
}
String fqcn = ClassContext.getFqcn(owner) + '#' + name;
String message = String.format(
"Field requires API level %1$d (current min is %2$d): %3$s",
api, minSdk, fqcn);
report(context, message, node, method, name, null,
SearchHints.create(FORWARD).matchJavaSymbol());
}
} else if (type == AbstractInsnNode.LDC_INSN) {
LdcInsnNode node = (LdcInsnNode) instruction;
if (node.cst instanceof Type) {
Type t = (Type) node.cst;
String className = t.getInternalName();
int api = mApiDatabase.getClassVersion(className);
if (api > minSdk) {
String fqcn = ClassContext.getFqcn(className);
String message = String.format(
"Class requires API level %1$d (current min is %2$d): %3$s",
api, minSdk, fqcn);
report(context, message, node, method,
className.substring(className.lastIndexOf('/') + 1), null,
SearchHints.create(FORWARD).matchJavaSymbol());
}
}
}
}
}
}
@SuppressWarnings("rawtypes") // ASM API
private boolean methodDefinedLocally(ClassNode classNode, String name, String desc) {
List methodList = classNode.methods;
for (Object m : methodList) {
MethodNode method = (MethodNode) m;
if (name.equals(method.name) && desc.equals(method.desc)) {
return true;
}
}
return false;
}
@SuppressWarnings("rawtypes") // ASM API
private void checkSwitchBlock(ClassContext context, ClassNode classNode, FieldInsnNode field,
MethodNode method, String name, String owner, int api, int minSdk) {
// Switch statements on enums are tricky. The compiler will generate a method
// which returns an array of the enum constants, indexed by their ordinal() values.
// However, we only want to complain if the code is actually referencing one of
// the non-available enum fields.
//
// For the android.graphics.PorterDuff.Mode enum for example, the first few items
// in the array are populated like this:
//
// L0
// ALOAD 0
// GETSTATIC android/graphics/PorterDuff$Mode.ADD : Landroid/graphics/PorterDuff$Mode;
// INVOKEVIRTUAL android/graphics/PorterDuff$Mode.ordinal ()I
// ICONST_1
// IASTORE
// L1
// GOTO L3
// L2
// FRAME FULL [[I] [java/lang/NoSuchFieldError]
// POP
// L3
// FRAME SAME
// ALOAD 0
// GETSTATIC android/graphics/PorterDuff$Mode.CLEAR : Landroid/graphics/PorterDuff$Mode;
// INVOKEVIRTUAL android/graphics/PorterDuff$Mode.ordinal ()I
// ICONST_2
// IASTORE
// ...
// So if we for example find that the "ADD" field isn't accessible, since it requires
// API 11, we need to
// (1) First find out what its ordinal number is. We can look at the following
// instructions to discover this; it's the "ICONST_1" and "IASTORE" instructions.
// (After ICONST_5 it moves on to BIPUSH 6, BIPUSH 7, etc.)
// (2) Find the corresponding *usage* of this switch method. For the above enum,
// the switch ordinal lookup method will be called
// "$SWITCH_TABLE$android$graphics$PorterDuff$Mode" with desc "()[I".
// This means we will be looking for an invocation in some other method which looks
// like this:
// INVOKESTATIC (current class).$SWITCH_TABLE$android$graphics$PorterDuff$Mode ()[I
// (obviously, it can be invoked more than once)
// Note that it can be used more than once in this class and all sites should be
// checked!
// (3) Look up the corresponding table switch, which should look something like this:
// INVOKESTATIC (current class).$SWITCH_TABLE$android$graphics$PorterDuff$Mode ()[I
// ALOAD 0
// INVOKEVIRTUAL android/graphics/PorterDuff$Mode.ordinal ()I
// IALOAD
// LOOKUPSWITCH
// 2: L1
// 11: L2
// default: L3
// Here we need to see if the LOOKUPSWITCH instruction is referencing our target
// case. Above we were looking for the "ADD" case which had ordinal 1. Since this
// isn't explicitly referenced, we can ignore this field reference.
AbstractInsnNode next = field.getNext();
if (next == null || next.getOpcode() != Opcodes.INVOKEVIRTUAL) {
return;
}
next = next.getNext();
if (next == null) {
return;
}
int ordinal = -1;
switch (next.getOpcode()) {
case Opcodes.ICONST_0: ordinal = 0; break;
case Opcodes.ICONST_1: ordinal = 1; break;
case Opcodes.ICONST_2: ordinal = 2; break;
case Opcodes.ICONST_3: ordinal = 3; break;
case Opcodes.ICONST_4: ordinal = 4; break;
case Opcodes.ICONST_5: ordinal = 5; break;
case Opcodes.BIPUSH: {
IntInsnNode iin = (IntInsnNode) next;
ordinal = iin.operand;
break;
}
default:
return;
}
// Find usages of this call site
List methodList = classNode.methods;
for (Object m : methodList) {
InsnList nodes = ((MethodNode) m).instructions;
for (int i = 0, n = nodes.size(); i < n; i++) {
AbstractInsnNode instruction = nodes.get(i);
if (instruction.getOpcode() != Opcodes.INVOKESTATIC){
continue;
}
MethodInsnNode node = (MethodInsnNode) instruction;
if (node.name.equals(method.name)
&& node.desc.equals(method.desc)
&& node.owner.equals(classNode.name)) {
// Find lookup switch
AbstractInsnNode target = getNextInstruction(node);
while (target != null) {
if (target.getOpcode() == Opcodes.LOOKUPSWITCH) {
LookupSwitchInsnNode lookup = (LookupSwitchInsnNode) target;
@SuppressWarnings("unchecked") // ASM API
List<Integer> keys = lookup.keys;
if (keys != null && keys.contains(ordinal)) {
String fqcn = ClassContext.getFqcn(owner) + '#' + name;
String message = String.format(
"Enum value requires API level %1$d " +
"(current min is %2$d): %3$s",
api, minSdk, fqcn);
report(context, message, lookup, (MethodNode) m, name, null,
SearchHints.create(FORWARD).matchJavaSymbol());
// Break out of the inner target search only; the switch
// statement could be used in other places in this class as
// well and we want to report all problematic usages.
break;
}
}
target = getNextInstruction(target);
}
}
}
}
}
/**
* Return the {@code @TargeTApi} level to use for the given {@code classNode};
* this will be the {@code @TargetApi} annotation on the class, or any outer
* methods (for anonymous inner classes) or outer classes (for inner classes)
* of the given class.
*/
private int getClassMinSdk(ClassContext context, ClassNode classNode) {
int classMinSdk = getLocalMinSdk(classNode.invisibleAnnotations);
if (classMinSdk != -1) {
return classMinSdk;
}
LintDriver driver = context.getDriver();
while (classNode != null) {
ClassNode prev = classNode;
classNode = driver.getOuterClassNode(classNode);
if (classNode != null) {
// TODO: Should this be "curr" instead?
if (prev.outerMethod != null) {
@SuppressWarnings("rawtypes") // ASM API
List methods = classNode.methods;
for (Object m : methods) {
MethodNode method = (MethodNode) m;
if (method.name.equals(prev.outerMethod)
&& method.desc.equals(prev.outerMethodDesc)) {
// Found the outer method for this anonymous class; check method
// annotations on it, then continue up the class hierarchy
int methodMinSdk = getLocalMinSdk(method.invisibleAnnotations);
if (methodMinSdk != -1) {
return methodMinSdk;
}
break;
}
}
}
classMinSdk = getLocalMinSdk(classNode.invisibleAnnotations);
if (classMinSdk != -1) {
return classMinSdk;
}
}
}
return -1;
}
/**
* Returns the minimum SDK to use according to the given annotation list, or
* -1 if no annotation was found.
*
* @param annotations a list of annotation nodes from ASM
* @return the API level to use for this node, or -1
*/
@SuppressWarnings({"unchecked", "rawtypes"})
private int getLocalMinSdk(List annotations) {
if (annotations != null) {
for (AnnotationNode annotation : (List<AnnotationNode>)annotations) {
String desc = annotation.desc;
if (desc.endsWith(TARGET_API_VMSIG)) {
if (annotation.values != null) {
for (int i = 0, n = annotation.values.size(); i < n; i += 2) {
String key = (String) annotation.values.get(i);
if (key.equals("value")) { //$NON-NLS-1$
Object value = annotation.values.get(i + 1);
if (value instanceof Integer) {
return ((Integer) value).intValue();
} else if (value instanceof List) {
List list = (List) value;
for (Object v : list) {
if (v instanceof Integer) {
return ((Integer) value).intValue();
}
}
}
}
}
}
}
}
}
return -1;
}
private void report(final ClassContext context, String message, AbstractInsnNode node,
MethodNode method, String patternStart, String patternEnd, SearchHints hints) {
int lineNumber = node != null ? ClassContext.findLineNumber(node) : -1;
// If looking for a constructor, the string we'll see in the source is not the
// method name (<init>) but the class name
if (patternStart != null && patternStart.equals(CONSTRUCTOR_NAME)
&& node instanceof MethodInsnNode) {
if (hints != null) {
hints = hints.matchConstructor();
}
patternStart = ((MethodInsnNode) node).owner;
}
if (patternStart != null) {
int index = patternStart.lastIndexOf('$');
if (index != -1) {
patternStart = patternStart.substring(index + 1);
}
index = patternStart.lastIndexOf('/');
if (index != -1) {
patternStart = patternStart.substring(index + 1);
}
}
Location location = context.getLocationForLine(lineNumber, patternStart, patternEnd,
hints);
context.report(UNSUPPORTED, method, node, location, message, null);
}
}