/* * 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.ANDROID_URI; import static com.android.SdkConstants.ATTR_CLASS; import static com.android.SdkConstants.ATTR_ID; import static com.android.SdkConstants.ATTR_LABEL_FOR; import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; import static com.android.SdkConstants.ATTR_NAME; import static com.android.SdkConstants.ATTR_PADDING_START; import static com.android.SdkConstants.ATTR_PARENT; import static com.android.SdkConstants.ATTR_TARGET_API; import static com.android.SdkConstants.ATTR_TEXT_IS_SELECTABLE; import static com.android.SdkConstants.BUTTON; import static com.android.SdkConstants.CHECK_BOX; import static com.android.SdkConstants.CLASS_CONSTRUCTOR; import static com.android.SdkConstants.CONSTRUCTOR_NAME; import static com.android.SdkConstants.PREFIX_ANDROID; import static com.android.SdkConstants.R_CLASS; import static com.android.SdkConstants.SWITCH; import static com.android.SdkConstants.TAG; import static com.android.SdkConstants.TAG_ITEM; import static com.android.SdkConstants.TAG_STYLE; import static com.android.SdkConstants.TARGET_API; import static com.android.SdkConstants.TOOLS_URI; import static com.android.SdkConstants.VIEW_TAG; import static com.android.tools.lint.detector.api.ClassContext.getFqcn; import static com.android.tools.lint.detector.api.ClassContext.getInternalName; 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 static com.android.utils.SdkUtils.getResourceFieldName; import com.android.SdkConstants; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.resources.ResourceFolderType; import com.android.sdklib.AndroidVersion; import com.android.sdklib.SdkVersionInfo; import com.android.tools.lint.client.api.IssueRegistry; import com.android.tools.lint.client.api.JavaParser; 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.DefaultPosition; import com.android.tools.lint.detector.api.Detector; import com.android.tools.lint.detector.api.Implementation; import com.android.tools.lint.detector.api.Issue; import com.android.tools.lint.detector.api.JavaContext; import com.android.tools.lint.detector.api.LintUtils; import com.android.tools.lint.detector.api.Location; import com.android.tools.lint.detector.api.Location.SearchHints; import com.android.tools.lint.detector.api.Position; 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.TextFormat; import com.android.tools.lint.detector.api.XmlContext; import com.android.utils.Pair; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; 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.FieldNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.IntInsnNode; import org.objectweb.asm.tree.JumpInsnNode; 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.objectweb.asm.tree.analysis.AnalyzerException; import org.w3c.dom.Attr; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collection; import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import lombok.ast.Annotation; import lombok.ast.AnnotationElement; import lombok.ast.AnnotationValue; import lombok.ast.AstVisitor; import lombok.ast.BinaryExpression; import lombok.ast.Case; import lombok.ast.Catch; import lombok.ast.ClassDeclaration; import lombok.ast.ConstructorDeclaration; import lombok.ast.ConstructorInvocation; import lombok.ast.Expression; import lombok.ast.ForwardingAstVisitor; import lombok.ast.If; import lombok.ast.ImportDeclaration; import lombok.ast.InlineIfExpression; import lombok.ast.IntegralLiteral; import lombok.ast.MethodDeclaration; import lombok.ast.MethodInvocation; import lombok.ast.Modifiers; import lombok.ast.Select; import lombok.ast.StrictListAccessor; import lombok.ast.StringLiteral; import lombok.ast.SuperConstructorInvocation; import lombok.ast.Switch; import lombok.ast.Try; import lombok.ast.TypeReference; import lombok.ast.VariableDefinition; import lombok.ast.VariableDefinitionEntry; import lombok.ast.VariableReference; /** * 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, Detector.JavaScanner { /** * Whether we flag variable, field, parameter and return type declarations of a type * not yet available. It appears Dalvik is very forgiving and doesn't try to preload * classes until actually needed, so there is no need to flag these, and in fact, * patterns used for supporting new and old versions sometimes declares these methods * and only conditionally end up actually accessing methods and fields, so only check * method and field accesses. */ private static final boolean CHECK_DECLARATIONS = false; private static final boolean AOSP_BUILD = System.getenv("ANDROID_BUILD_TOP") != null; //$NON-NLS-1$ /** Accessing an unsupported API */ @SuppressWarnings("unchecked") public static final Issue UNSUPPORTED = Issue.create( "NewApi", //$NON-NLS-1$ "Calling new methods on older 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 `build.gradle` or `AndroidManifest.xml` files.\n" + "\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.\n" + "\n" + "If you are deliberately setting `android:` attributes in style definitions, " + "make sure you place this in a `values-vNN` folder in order to avoid running " + "into runtime conflicts on certain devices where manufacturers have added " + "custom attributes whose ids conflict with the new ones on later platforms.\n" + "\n" + "Similarly, you can use tools:targetApi=\"11\" in an XML file to indicate that " + "the element will only be inflated in an adequate context.", Category.CORRECTNESS, 6, Severity.ERROR, new Implementation( ApiDetector.class, EnumSet.of(Scope.CLASS_FILE, Scope.RESOURCE_FILE, Scope.MANIFEST), Scope.RESOURCE_FILE_SCOPE, Scope.CLASS_FILE_SCOPE, Scope.MANIFEST_SCOPE)); /** Accessing an inlined API on older platforms */ public static final Issue INLINED = Issue.create( "InlinedApi", //$NON-NLS-1$ "Using inlined constants on older versions", "This check scans through all the Android API field references in the application " + "and flags certain constants, such as static final integers and Strings, " + "which were introduced in later versions. These will actually be copied " + "into the class files rather than being referenced, which means that " + "the value is available even when running on older devices. In some " + "cases that's fine, and in other cases it can result in a runtime " + "crash or incorrect behavior. It depends on the context, so consider " + "the code carefully and device whether it's safe and can be suppressed " + "or whether the code needs tbe guarded.\n" + "\n" + "If you really want to use this API and don't need to support older devices just " + "set the `minSdkVersion` in your `build.gradle` or `AndroidManifest.xml` files." + "\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.\n", Category.CORRECTNESS, 6, Severity.WARNING, new Implementation( ApiDetector.class, Scope.JAVA_FILE_SCOPE)); /** Accessing an unsupported API */ public static final Issue OVERRIDE = Issue.create( "Override", //$NON-NLS-1$ "Method conflicts with new inherited method", "Suppose you are building against Android API 8, and you've subclassed Activity. " + "In your subclass you add a new method called `isDestroyed`(). At some later point, " + "a method of the same name and signature is added to Android. Your method will " + "now override the Android method, and possibly break its contract. Your method " + "is not calling `super.isDestroyed()`, since your compilation target doesn't " + "know about the method.\n" + "\n" + "The above scenario is what this lint detector looks for. The above example is " + "real, since `isDestroyed()` was added in API 17, but it will be true for *any* " + "method you have added to a subclass of an Android class where your build target " + "is lower than the version the method was introduced in.\n" + "\n" + "To fix this, either rename your method, or if you are really trying to augment " + "the builtin method if available, switch to a higher build target where you can " + "deliberately add `@Override` on your overriding method, and call `super` if " + "appropriate etc.\n", Category.CORRECTNESS, 6, Severity.ERROR, new Implementation( ApiDetector.class, Scope.CLASS_FILE_SCOPE)); /** Accessing an inlined API on older platforms */ public static final Issue UNUSED = Issue.create( "UnusedAttribute", //$NON-NLS-1$ "Attribute unused on older versions", "This check finds attributes set in XML files that were introduced in a version " + "newer than the oldest version targeted by your application (with the " + "`minSdkVersion` attribute).\n" + "\n" + "This is not an error; the application will simply ignore the attribute. However, " + "if the attribute is important to the appearance of functionality of your " + "application, you should consider finding an alternative way to achieve the " + "same result with only available attributes, and then you can optionally create " + "a copy of the layout in a layout-vNN folder which will be used on API NN or " + "higher where you can take advantage of the newer attribute.\n" + "\n" + "Note: This check does not only apply to attributes. For example, some tags can be " + "unused too, such as the new `<tag>` element in layouts introduced in API 21.", Category.CORRECTNESS, 6, Severity.WARNING, new Implementation( ApiDetector.class, Scope.RESOURCE_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$ public static final String ENUM_SWITCH_PREFIX = "$SwitchMap$"; //$NON-NLS-1$ private static final String TAG_RIPPLE = "ripple"; private static final String TAG_VECTOR = "vector"; private static final String TAG_ANIMATED_VECTOR = "animated-vector"; private static final String TAG_ANIMATED_SELECTOR = "animated-selector"; private static final String SDK_INT = "SDK_INT"; private static final String ANDROID_OS_BUILD_VERSION = "android/os/Build$VERSION"; protected ApiLookup mApiDatabase; private boolean mWarnedMissingDb; private int mMinApi = -1; private Map<String, List<Pair<String, Location>>> mPendingFields; /** Constructs a new API check */ public ApiDetector() { } @NonNull @Override public 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. if (mApiDatabase == null && !mWarnedMissingDb) { mWarnedMissingDb = true; context.report(IssueRegistry.LINT_ERROR, Location.create(context.file), "Can't find API database; API check not performed"); } } // ---- 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; } int attributeApiLevel = -1; if (ANDROID_URI.equals(attribute.getNamespaceURI())) { String name = attribute.getLocalName(); if (!(name.equals(ATTR_LAYOUT_WIDTH) && !(name.equals(ATTR_LAYOUT_HEIGHT)) && !(name.equals(ATTR_ID)))) { String owner = "android/R$attr"; //$NON-NLS-1$ attributeApiLevel = mApiDatabase.getFieldVersion(owner, name); int minSdk = getMinSdk(context); if (attributeApiLevel > minSdk && attributeApiLevel > context.getFolderVersion() && attributeApiLevel > getLocalMinSdk(attribute.getOwnerElement()) && !isBenignUnusedAttribute(name) && !isAlreadyWarnedDrawableFile(context, attribute, attributeApiLevel)) { if (RtlDetector.isRtlAttributeName(name)) { // No need to warn for example that // "layout_alignParentEnd will only be used in API level 17 and higher" // since we have a dedicated RTL lint rule dealing with those attributes // However, paddingStart in particular is known to cause crashes // when used on TextViews (and subclasses of TextViews), on some // devices, because vendor specific attributes conflict with the // later-added framework resources, and these are apparently read // by the text views: if (name.equals(ATTR_PADDING_START) && viewMayExtendTextView(attribute.getOwnerElement())) { Location location = context.getLocation(attribute); String message = String.format( "Attribute `%1$s` referenced here can result in a crash on " + "some specific devices older than API %2$d " + "(current min is %3$d)", attribute.getLocalName(), attributeApiLevel, minSdk); context.report(UNSUPPORTED, attribute, location, message); } } else { Location location = context.getLocation(attribute); String message = String.format( "Attribute `%1$s` is only used in API level %2$d and higher " + "(current min is %3$d)", attribute.getLocalName(), attributeApiLevel, minSdk); context.report(UNUSED, attribute, location, message); } } } // Special case: // the dividers attribute is present in API 1, but it won't be read on older // versions, so don't flag the common pattern // android:divider="?android:attr/dividerHorizontal" // since this will work just fine. See issue 67440 for more. if (name.equals("divider")) { return; } } String value = attribute.getValue(); String owner = null; String name = null; String prefix; if (value.startsWith(ANDROID_PREFIX)) { prefix = ANDROID_PREFIX; } else if (value.startsWith(ANDROID_THEME_PREFIX)) { prefix = ANDROID_THEME_PREFIX; } else if (value.startsWith(PREFIX_ANDROID) && ATTR_NAME.equals(attribute.getName()) && TAG_ITEM.equals(attribute.getOwnerElement().getTagName()) && attribute.getOwnerElement().getParentNode() != null && TAG_STYLE.equals(attribute.getOwnerElement().getParentNode().getNodeName())) { owner = "android/R$attr"; //$NON-NLS-1$ name = value.substring(PREFIX_ANDROID.length()); prefix = null; } else if (value.startsWith(PREFIX_ANDROID) && ATTR_PARENT.equals(attribute.getName()) && TAG_STYLE.equals(attribute.getOwnerElement().getTagName())) { owner = "android/R$style"; //$NON-NLS-1$ name = getResourceFieldName(value.substring(PREFIX_ANDROID.length())); prefix = null; } else { return; } if (owner == null) { // Convert @android:type/foo into android/R$type and "foo" int index = value.indexOf('/', prefix.length()); if (index != -1) { owner = "android/R$" //$NON-NLS-1$ + value.substring(prefix.length(), index); name = getResourceFieldName(value.substring(index + 1)); } else if (value.startsWith(ANDROID_THEME_PREFIX)) { owner = "android/R$attr"; //$NON-NLS-1$ name = value.substring(ANDROID_THEME_PREFIX.length()); } else { return; } } int api = mApiDatabase.getFieldVersion(owner, name); int minSdk = getMinSdk(context); if (api > minSdk && api > context.getFolderVersion() && api > getLocalMinSdk(attribute.getOwnerElement())) { // Don't complain about resource references in the tools namespace, // such as for example "tools:layout="@android:layout/list_content", // used only for designtime previews if (TOOLS_URI.equals(attribute.getNamespaceURI())) { return; } //noinspection StatementWithEmptyBody if (attributeApiLevel >= api) { // The attribute will only be *read* on platforms >= attributeApiLevel. // If this isn't lower than the attribute reference's API level, it // won't be a problem } else if (attributeApiLevel > minSdk) { String attributeName = attribute.getLocalName(); Location location = context.getLocation(attribute); String message = String.format( "`%1$s` requires API level %2$d (current min is %3$d), but note " + "that attribute `%4$s` is only used in API level %5$d " + "and higher", name, api, minSdk, attributeName, attributeApiLevel); context.report(UNSUPPORTED, attribute, location, message); } else { 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); } } } /** * Returns true if the view tag is possibly a text view. It may not be certain, * but will err on the side of caution (for example, any custom view is considered * to be a potential text view.) */ private static boolean viewMayExtendTextView(@NonNull Element element) { String tag = element.getTagName(); if (tag.equals(SdkConstants.VIEW_TAG)) { tag = element.getAttribute(ATTR_CLASS); if (tag == null || tag.isEmpty()) { return false; } } //noinspection SimplifiableIfStatement if (tag.indexOf('.') != -1) { // Custom views: not sure. Err on the side of caution. return true; } return tag.contains("Text") // TextView, EditText, etc || tag.contains(BUTTON) // Button, ToggleButton, etc || tag.equals("DigitalClock") || tag.equals("Chronometer") || tag.equals(CHECK_BOX) || tag.equals(SWITCH); } /** * Returns true if this attribute is in a drawable document with one of the * root tags that require API 21 */ private static boolean isAlreadyWarnedDrawableFile(@NonNull XmlContext context, @NonNull Attr attribute, int attributeApiLevel) { // Don't complain if it's in a drawable file where we've already // flagged the root drawable type as being unsupported if (context.getResourceFolderType() == ResourceFolderType.DRAWABLE && attributeApiLevel == 21) { String root = attribute.getOwnerDocument().getDocumentElement().getTagName(); if (TAG_RIPPLE.equals(root) || TAG_VECTOR.equals(root) || TAG_ANIMATED_VECTOR.equals(root) || TAG_ANIMATED_SELECTOR.equals(root)) { return true; } } return false; } /** * Is the given attribute a "benign" unused attribute, one we probably don't need to * flag to the user as not applicable on all versions? These are typically attributes * which add some nice platform behavior when available, but that are not critical * and developers would not typically need to be aware of to try to implement workarounds * on older platforms. */ public static boolean isBenignUnusedAttribute(@NonNull String name) { return ATTR_LABEL_FOR.equals(name) || ATTR_TEXT_IS_SELECTABLE.equals(name); } @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 (folderType == ResourceFolderType.DRAWABLE) { checkElement(context, element, TAG_RIPPLE, 21, UNSUPPORTED); checkElement(context, element, TAG_VECTOR, 21, UNSUPPORTED); checkElement(context, element, TAG_ANIMATED_SELECTOR, 21, UNSUPPORTED); checkElement(context, element, TAG_ANIMATED_VECTOR, 21, UNSUPPORTED); } 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.contains(ANDROID_PREFIX)) { 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 = getResourceFieldName(text.substring(index + 1)); int api = mApiDatabase.getFieldVersion(owner, name); int minSdk = getMinSdk(context); if (api > minSdk && api > context.getFolderVersion() && api > getLocalMinSdk(element)) { 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); } } } } } } else { if (VIEW_TAG.equals(tag)) { tag = element.getAttribute(ATTR_CLASS); if (tag == null || tag.isEmpty()) { return; } } else { // TODO: Complain if <tag> is used at the root level! checkElement(context, element, TAG, 21, UNUSED); } // Check widgets to make sure they're available in this version of the SDK. if (tag.indexOf('.') != -1) { // Custom views aren't in the index return; } String fqn = "android/widget/" + tag; //$NON-NLS-1$ if (tag.equals("TextureView")) { //$NON-NLS-1$ fqn = "android/view/TextureView"; //$NON-NLS-1$ } // TODO: Consider other widgets outside of android.widget.* int api = mApiDatabase.getClassVersion(fqn); int minSdk = getMinSdk(context); if (api > minSdk && api > context.getFolderVersion() && api > getLocalMinSdk(element)) { 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); } } } /** Checks whether the given element is the given tag, and if so, whether it satisfied * the minimum version that the given tag is supported in */ private void checkElement(@NonNull XmlContext context, @NonNull Element element, @NonNull String tag, int api, @NonNull Issue issue) { if (tag.equals(element.getTagName())) { int minSdk = getMinSdk(context); if (api > minSdk && api > context.getFolderVersion() && api > getLocalMinSdk(element)) { Location location = context.getLocation(element); String message; if (issue == UNSUPPORTED) { message = String.format( "`<%1$s>` requires API level %2$d (current min is %3$d)", tag, api, minSdk); } else { assert issue == UNUSED : issue; message = String.format( "`<%1$s>` is only used in API level %2$d and higher " + "(current min is %3$d)", tag, api, minSdk); } context.report(issue, element, location, message); } } } protected int getMinSdk(Context context) { if (mMinApi == -1) { AndroidVersion minSdkVersion = context.getMainProject().getMinSdkVersion(); mMinApi = minSdkVersion.getFeatureLevel(); } return mMinApi; } // ---- Implements ClassScanner ---- @SuppressWarnings("rawtypes") // ASM API @Override public void checkClass(@NonNull final 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; if (methodList.isEmpty()) { return; } boolean checkCalls = context.isEnabled(UNSUPPORTED) || context.isEnabled(INLINED); boolean checkMethods = context.isEnabled(OVERRIDE) && context.getMainProject().getBuildSdk() >= 1; String frameworkParent = null; if (checkMethods) { LintDriver driver = context.getDriver(); String owner = classNode.superName; while (owner != null) { // For virtual dispatch, walk up the inheritance chain checking // each inherited method if ((owner.startsWith("android/") //$NON-NLS-1$ && !owner.startsWith("android/support/")) //$NON-NLS-1$ || owner.startsWith("java/") //$NON-NLS-1$ || owner.startsWith("javax/")) { //$NON-NLS-1$ frameworkParent = owner; break; } owner = driver.getSuperClass(owner); } if (frameworkParent == null) { checkMethods = false; } } if (checkCalls) { // Check implements/extends if (classNode.superName != null) { String signature = classNode.superName; checkExtendsClass(context, classNode, classMinSdk, signature); } if (classNode.interfaces != null) { @SuppressWarnings("unchecked") // ASM API List<String> interfaceList = classNode.interfaces; for (String signature : interfaceList) { checkExtendsClass(context, classNode, classMinSdk, signature); } } } for (Object m : methodList) { MethodNode method = (MethodNode) m; int minSdk = getLocalMinSdk(method.invisibleAnnotations); if (minSdk == -1) { minSdk = classMinSdk; } InsnList nodes = method.instructions; if (checkMethods && Character.isJavaIdentifierStart(method.name.charAt(0))) { int buildSdk = context.getMainProject().getBuildSdk(); String name = method.name; assert frameworkParent != null; int api = mApiDatabase.getCallVersion(frameworkParent, name, method.desc); if (api > buildSdk && buildSdk != -1) { // TODO: Don't complain if it's annotated with @Override; that means // somehow the build target isn't correct. String fqcn; String owner = classNode.name; if (CONSTRUCTOR_NAME.equals(name)) { fqcn = "new " + ClassContext.getFqcn(owner); //$NON-NLS-1$ } else { fqcn = ClassContext.getFqcn(owner) + '#' + name; } String message = String.format( "This method is not overriding anything with the current build " + "target, but will in API level %1$d (current target is %2$d): `%3$s`", api, buildSdk, fqcn); Location location = context.getLocation(method, classNode); context.report(OVERRIDE, method, null, location, message); } } if (!checkCalls) { continue; } if (CHECK_DECLARATIONS) { // 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)); } // If you're simply calling super.X from method X, even if method X // is in a higher API level than the minSdk, we're generally safe; // that method should only be called by the framework on the right // API levels. (There is a danger of somebody calling that method // locally in other contexts, but this is hopefully unlikely.) if (instruction.getOpcode() == Opcodes.INVOKESPECIAL && name.equals(method.name) && desc.equals(method.desc) && // We specifically exclude constructors from this check, // because we do want to flag constructors requiring the // new API level; it's highly likely that the constructor // is called by local code so you should specifically // investigate this as a developer !name.equals(CONSTRUCTOR_NAME)) { break; } if (isWithinSdkConditional(context, classNode, method, instruction, api)) { break; } report(context, message, node, method, name, null, SearchHints.create(FORWARD).matchJavaSymbol()); break; } // For virtual dispatch, walk up the inheritance chain checking // each inherited method if (owner.startsWith("android/") //$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 // -- unless it's the support library which is also in // the android/ namespace: if (owner.startsWith("android/support/")) { //$NON-NLS-1$ owner = context.getDriver().getSuperClass(owner); } else { owner = null; } } else if (owner.startsWith("java/")) { //$NON-NLS-1$ if (owner.equals(LocaleDetector.DATE_FORMAT_OWNER)) { checkSimpleDateFormat(context, method, node, minSdk); } // Already inlined; see comment above 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; } if (isSkippedEnumSwitch(context, classNode, method, node, owner, api)) { continue; } if (isWithinSdkConditional(context, classNode, method, instruction, api)) { continue; } String fqcn = ClassContext.getFqcn(owner) + '#' + name; if (mPendingFields != null) { mPendingFields.remove(fqcn); } 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()); } } } } } } private void checkExtendsClass(ClassContext context, ClassNode classNode, int classMinSdk, String signature) { int api = mApiDatabase.getClassVersion(signature); if (api > classMinSdk) { String fqcn = ClassContext.getFqcn(signature); String message = String.format( "Class requires API level %1$d (current min is %2$d): `%3$s`", api, classMinSdk, fqcn); String name = signature.substring(signature.lastIndexOf('/') + 1); name = name.substring(name.lastIndexOf('$') + 1); SearchHints hints = SearchHints.create(BACKWARD).matchJavaSymbol(); int lineNumber = ClassContext.findLineNumber(classNode); Location location = context.getLocationForLine(lineNumber, name, null, hints); context.report(UNSUPPORTED, location, message); } } private static void checkSimpleDateFormat(ClassContext context, MethodNode method, MethodInsnNode node, int minSdk) { if (minSdk >= 9) { // Already OK return; } if (node.name.equals(CONSTRUCTOR_NAME) && !node.desc.equals("()V")) { //$NON-NLS-1$ // Check first argument AbstractInsnNode prev = LintUtils.getPrevInstruction(node); if (prev != null && !node.desc.equals("(Ljava/lang/String;)V")) { //$NON-NLS-1$ prev = LintUtils.getPrevInstruction(prev); } if (prev != null && prev.getOpcode() == Opcodes.LDC) { LdcInsnNode ldc = (LdcInsnNode) prev; Object cst = ldc.cst; if (cst instanceof String) { String pattern = (String) cst; boolean isEscaped = false; for (int i = 0; i < pattern.length(); i++) { char c = pattern.charAt(i); if (c == '\'') { isEscaped = !isEscaped; } else if (!isEscaped && (c == 'L' || c == 'c')) { String message = String.format( "The pattern character '%1$c' requires API level 9 (current " + "min is %2$d) : \"`%3$s`\"", c, minSdk, pattern); report(context, message, node, method, pattern, null, SearchHints.create(FORWARD)); return; } } } } } } @SuppressWarnings("rawtypes") // ASM API private static 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 static 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; 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); } } } } } private static boolean isEnumSwitchInitializer(ClassNode classNode) { @SuppressWarnings("rawtypes") // ASM API List fieldList = classNode.fields; for (Object f : fieldList) { FieldNode field = (FieldNode) f; if (field.name.startsWith(ENUM_SWITCH_PREFIX)) { return true; } } return false; } private static MethodNode findEnumSwitchUsage(ClassNode classNode, String owner) { String target = ENUM_SWITCH_PREFIX + owner.replace('/', '$'); @SuppressWarnings("rawtypes") // ASM API List methodList = classNode.methods; for (Object f : methodList) { MethodNode method = (MethodNode) f; InsnList nodes = method.instructions; for (int i = 0, n = nodes.size(); i < n; i++) { AbstractInsnNode instruction = nodes.get(i); if (instruction.getOpcode() == Opcodes.GETSTATIC) { FieldInsnNode field = (FieldInsnNode) instruction; if (field.name.equals(target)) { return method; } } } } return null; } private static boolean isSkippedEnumSwitch(ClassContext context, ClassNode classNode, MethodNode method, FieldInsnNode node, String owner, int api) { // Enum-style switches are handled in a different way: it generates // an innerclass where the class initializer creates a mapping from // the ordinals to the corresponding values. // Here we need to check to see if the call site which *used* the // table switch had a suppress node on it (or up that node's parent // chain AbstractInsnNode next = getNextInstruction(node); if (next != null && next.getOpcode() == Opcodes.INVOKEVIRTUAL && CLASS_CONSTRUCTOR.equals(method.name) && ORDINAL_METHOD.equals(((MethodInsnNode) next).name) && classNode.outerClass != null && isEnumSwitchInitializer(classNode)) { LintDriver driver = context.getDriver(); ClassNode outer = driver.getOuterClassNode(classNode); if (outer != null) { MethodNode switchUser = findEnumSwitchUsage(outer, owner); if (switchUser != null) { // Is the API check suppressed at the call site? if (driver.isSuppressed(UNSUPPORTED, outer, switchUser, null)) { return true; } // Is there a @TargetAPI annotation on the method or // class referencing this switch map class? if (getLocalMinSdk(switchUser.invisibleAnnotations) >= api || getLocalMinSdk(outer.invisibleAnnotations) >= api) { return true; } } } } return false; } /** * 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 static 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 static 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; } } } } } } } return -1; } /** * Returns the minimum SDK to use in the given element context, or -1 if no * {@code tools:targetApi} attribute was found. * * @param element the element to look at, including parents * @return the API level to use for this element, or -1 */ private static int getLocalMinSdk(@NonNull Element element) { while (element != null) { String targetApi = element.getAttributeNS(TOOLS_URI, ATTR_TARGET_API); if (targetApi != null && !targetApi.isEmpty()) { if (Character.isDigit(targetApi.charAt(0))) { try { return Integer.parseInt(targetApi); } catch (NumberFormatException nufe) { break; } } else { return SdkVersionInfo.getApiByBuildCode(targetApi, true); } } Node parent = element.getParentNode(); if (parent != null && parent.getNodeType() == Node.ELEMENT_NODE) { element = (Element) parent; } else { break; } } return -1; } private static 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); } @Override public void afterCheckProject(@NonNull Context context) { if (mPendingFields != null) { for (List<Pair<String, Location>> list : mPendingFields.values()) { for (Pair<String, Location> pair : list) { String message = pair.getFirst(); Location location = pair.getSecond(); context.report(INLINED, location, message); } } } super.afterCheckProject(context); } // ---- Implements JavaScanner ---- @Nullable @Override public AstVisitor createJavaVisitor(@NonNull JavaContext context) { if (mApiDatabase == null) { return new ForwardingAstVisitor() { }; } return new ApiVisitor(context); } @Nullable @Override public List<Class<? extends lombok.ast.Node>> getApplicableNodeTypes() { List<Class<? extends lombok.ast.Node>> types = new ArrayList<Class<? extends lombok.ast.Node>>(2); types.add(ImportDeclaration.class); types.add(Select.class); types.add(MethodDeclaration.class); types.add(ConstructorDeclaration.class); types.add(VariableDefinitionEntry.class); types.add(VariableReference.class); types.add(Try.class); return types; } /** * Checks whether the given instruction is a benign usage of a constant defined in * a later version of Android than the application's {@code minSdkVersion}. * * @param node the instruction to check * @param name the name of the constant * @param owner the field owner * @return true if the given usage is safe on older versions than the introduction * level of the constant */ public static boolean isBenignConstantUsage( @Nullable lombok.ast.Node node, @NonNull String name, @NonNull String owner) { if (owner.equals("android/os/Build$VERSION_CODES")) { //$NON-NLS-1$ // These constants are required for compilation, not execution // and valid code checks it even on older platforms return true; } if (owner.equals("android/view/ViewGroup$LayoutParams") //$NON-NLS-1$ && name.equals("MATCH_PARENT")) { //$NON-NLS-1$ return true; } if (owner.equals("android/widget/AbsListView") //$NON-NLS-1$ && ((name.equals("CHOICE_MODE_NONE") //$NON-NLS-1$ || name.equals("CHOICE_MODE_MULTIPLE") //$NON-NLS-1$ || name.equals("CHOICE_MODE_SINGLE")))) { //$NON-NLS-1$ // android.widget.ListView#CHOICE_MODE_MULTIPLE and friends have API=1, // but in API 11 it was moved up to the parent class AbsListView. // Referencing AbsListView#CHOICE_MODE_MULTIPLE technically requires API 11, // but the constant is the same as the older version, so accept this without // warning. return true; } // Gravity#START and Gravity#END are okay; these were specifically written to // be backwards compatible (by using the same lower bits for START as LEFT and // for END as RIGHT) if ("android/view/Gravity".equals(owner) //$NON-NLS-1$ && ("START".equals(name) || "END".equals(name))) { //$NON-NLS-1$ //$NON-NLS-2$ return true; } if (node == null) { return false; } // It's okay to reference the constant as a case constant (since that // code path won't be taken) or in a condition of an if statement lombok.ast.Node curr = node.getParent(); while (curr != null) { Class<? extends lombok.ast.Node> nodeType = curr.getClass(); if (nodeType == Case.class) { Case caseStatement = (Case) curr; Expression condition = caseStatement.astCondition(); return condition != null && isAncestor(condition, node); } else if (nodeType == If.class) { If ifStatement = (If) curr; Expression condition = ifStatement.astCondition(); return condition != null && isAncestor(condition, node); } else if (nodeType == InlineIfExpression.class) { InlineIfExpression ifStatement = (InlineIfExpression) curr; Expression condition = ifStatement.astCondition(); return condition != null && isAncestor(condition, node); } curr = curr.getParent(); } return false; } private static boolean isAncestor( @NonNull lombok.ast.Node ancestor, @Nullable lombok.ast.Node node) { while (node != null) { if (node == ancestor) { return true; } node = node.getParent(); } return false; } private final class ApiVisitor extends ForwardingAstVisitor { private JavaContext mContext; private Map<String, String> mClassToImport = Maps.newHashMap(); private List<String> mStarImports; private Set<String> mLocalVars; private lombok.ast.Node mCurrentMethod; private Set<String> mFields; private List<String> mStaticStarImports; private ApiVisitor(JavaContext context) { mContext = context; } @Override public boolean visitImportDeclaration(ImportDeclaration node) { if (node.astStarImport()) { // Similarly, if you're inheriting from a constants class, figure out // how that works... :=( String fqcn = node.asFullyQualifiedName(); int strip = fqcn.lastIndexOf('*'); if (strip != -1) { strip = fqcn.lastIndexOf('.', strip); if (strip != -1) { String pkgName = getInternalName(fqcn.substring(0, strip)); if (ApiLookup.isRelevantOwner(pkgName)) { if (node.astStaticImport()) { if (mStaticStarImports == null) { mStaticStarImports = Lists.newArrayList(); } mStaticStarImports.add(pkgName); } else { if (mStarImports == null) { mStarImports = Lists.newArrayList(); } mStarImports.add(pkgName); } } } } } else if (node.astStaticImport()) { String fqcn = node.asFullyQualifiedName(); String fieldName = getInternalName(fqcn); int index = fieldName.lastIndexOf('$'); if (index != -1) { String owner = fieldName.substring(0, index); String name = fieldName.substring(index + 1); checkField(node, name, owner); } } else { // Store in map -- if it's "one of ours" // Use override detector's map for that purpose String fqcn = node.asFullyQualifiedName(); int last = fqcn.lastIndexOf('.'); if (last != -1) { String className = fqcn.substring(last + 1); mClassToImport.put(className, fqcn); } } return super.visitImportDeclaration(node); } @Override public boolean visitSelect(Select node) { boolean result = super.visitSelect(node); if (node.getParent() instanceof Select) { // We only want to look at the leaf expressions; e.g. if you have // "foo.bar.baz" we only care about the select foo.bar.baz, not foo.bar return result; } // See if this corresponds to a field reference. We assume it's a field if // it's a select (x.y) and either the identifier y is capitalized (e.g. // foo.VIEW_MASK) or if it's a member of an R class (R.id.foo). String name = node.astIdentifier().astValue(); boolean isField = Character.isUpperCase(name.charAt(0)); if (!isField) { // See if there's an R class Select current = node; while (current != null) { Expression operand = current.astOperand(); if (operand instanceof Select) { current = (Select) operand; if (R_CLASS.equals(current.astIdentifier().astValue())) { isField = true; break; } } else if (operand instanceof VariableReference) { VariableReference reference = (VariableReference) operand; if (R_CLASS.equals(reference.astIdentifier().astValue())) { isField = true; } break; } else { break; } } } if (isField) { Expression operand = node.astOperand(); if (operand.getClass() == Select.class) { // Possibly a fully qualified name in place String cls = operand.toString(); // See if it's an imported class with an inner class // (e.g. Manifest.permission.FIELD) if (Character.isUpperCase(cls.charAt(0))) { int firstDot = cls.indexOf('.'); if (firstDot != -1) { String base = cls.substring(0, firstDot); String fqcn = mClassToImport.get(base); if (fqcn != null) { // Yes imported String owner = getInternalName(fqcn + cls.substring(firstDot)); checkField(node, name, owner); return result; } // Might be a star import: have to iterate and check here if (mStarImports != null) { for (String packagePrefix : mStarImports) { String owner = getInternalName(packagePrefix + '/' + cls); if (checkField(node, name, owner)) { mClassToImport.put(name, owner); return result; } } } } } // See if it's a fully qualified reference in place String owner = getInternalName(cls); checkField(node, name, owner); return result; } else if (operand.getClass() == VariableReference.class) { String className = ((VariableReference) operand).astIdentifier().astValue(); // Not a FQCN that we care about: look in imports String fqcn = mClassToImport.get(className); if (fqcn != null) { // Yes imported String owner = getInternalName(fqcn); checkField(node, name, owner); return result; } if (Character.isUpperCase(className.charAt(0))) { // Might be a star import: have to iterate and check here if (mStarImports != null) { for (String packagePrefix : mStarImports) { String owner = getInternalName(packagePrefix) + '/' + className; if (checkField(node, name, owner)) { mClassToImport.put(name, owner); return result; } } } } } } return result; } @Override public boolean visitVariableReference(VariableReference node) { boolean result = super.visitVariableReference(node); if (node.getParent() != null) { lombok.ast.Node parent = node.getParent(); Class<? extends lombok.ast.Node> parentClass = parent.getClass(); if (parentClass == Select.class || parentClass == Switch.class // look up on the switch expression type || parentClass == Case.class || parentClass == ConstructorInvocation.class || parentClass == SuperConstructorInvocation.class || parentClass == AnnotationElement.class) { return result; } if (parent instanceof MethodInvocation && ((MethodInvocation) parent).astOperand() == node) { return result; } else if (parent instanceof BinaryExpression) { BinaryExpression expression = (BinaryExpression) parent; if (expression.astLeft() == node) { return result; } } } String name = node.astIdentifier().astValue(); if (Character.isUpperCase(name.charAt(0)) && (mLocalVars == null || !mLocalVars.contains(name)) && (mFields == null || !mFields.contains(name))) { // Potential field reference: check it if (mStaticStarImports != null) { for (String owner : mStaticStarImports) { if (checkField(node, name, owner)) { break; } } } } return result; } @Override public boolean visitVariableDefinitionEntry(VariableDefinitionEntry node) { if (mCurrentMethod != null) { if (mLocalVars == null) { mLocalVars = Sets.newHashSet(); } mLocalVars.add(node.astName().astValue()); } else { if (mFields == null) { mFields = Sets.newHashSet(); } mFields.add(node.astName().astValue()); } return super.visitVariableDefinitionEntry(node); } @Override public boolean visitMethodDeclaration(MethodDeclaration node) { mLocalVars = null; mCurrentMethod = node; return super.visitMethodDeclaration(node); } @Override public boolean visitConstructorDeclaration(ConstructorDeclaration node) { mLocalVars = null; mCurrentMethod = node; return super.visitConstructorDeclaration(node); } @Override public boolean visitTry(Try node) { Object nativeNode = node.getNativeNode(); if (nativeNode != null && nativeNode.getClass().getName().equals( "org.eclipse.jdt.internal.compiler.ast.TryStatement")) { boolean isTryWithResources = false; try { Field field = nativeNode.getClass().getDeclaredField("resources"); Object value = field.get(nativeNode); if (value instanceof Object[]) { Object[] resources = (Object[]) value; isTryWithResources = resources.length > 0; } } catch (NoSuchFieldException e) { // Unexpected: ECJ parser internals have changed; can't detect try block } catch (IllegalAccessException e) { // Unexpected: ECJ parser internals have changed; can't detect try block } if (isTryWithResources) { int minSdk = getMinSdk(mContext); int api = 19; // minSdk for try with resources if (api > minSdk && api > getLocalMinSdk(node)) { Location location = mContext.getLocation(node); String message = String.format("Try-with-resources requires " + "API level %1$d (current min is %2$d)", api, minSdk); LintDriver driver = mContext.getDriver(); if (!driver.isSuppressed(mContext, UNSUPPORTED, node)) { mContext.report(UNSUPPORTED, node, location, message); } } } else { // Special case: check types of catch block variables; these apparently // need to be available at runtime even if there are no explicit calls for (Catch c : node.astCatches()) { VariableDefinition variableDefinition = c.astExceptionDeclaration(); TypeReference typeReference = variableDefinition.astTypeReference(); String fqcn = null; JavaParser.ResolvedNode resolved = mContext.resolve(typeReference); if (resolved != null) { fqcn = resolved.getSignature(); } else if (typeReference.getTypeName().equals( "ReflectiveOperationException")) { fqcn = "java.lang.ReflectiveOperationException"; } if (fqcn != null) { String owner = getInternalName(fqcn); int api = mApiDatabase.getClassVersion(owner); int minSdk = getMinSdk(mContext); if (api > minSdk && api > getLocalMinSdk(typeReference)) { Location location = mContext.getLocation(typeReference); String message = String.format( "Class requires API level %1$d (current min is %2$d): `%3$s`", api, minSdk, fqcn); LintDriver driver = mContext.getDriver(); if (!driver.isSuppressed(mContext, UNSUPPORTED, typeReference)) { mContext.report(UNSUPPORTED, typeReference, location, message); } } } } } } return super.visitTry(node); } @Override public void endVisit(lombok.ast.Node node) { if (node == mCurrentMethod) { mCurrentMethod = null; } super.endVisit(node); } /** * Checks a Java source field reference. Returns true if the field is known * regardless of whether it's an invalid field or not */ private boolean checkField( @NonNull lombok.ast.Node node, @NonNull String name, @NonNull String owner) { int api = mApiDatabase.getFieldVersion(owner, name); if (api != -1) { int minSdk = getMinSdk(mContext); if (api > minSdk && api > getLocalMinSdk(node)) { if (isBenignConstantUsage(node, name, owner)) { return true; } Location location = mContext.getLocation(node); String fqcn = getFqcn(owner) + '#' + name; if (node instanceof ImportDeclaration) { // Replace import statement location range with just // the identifier part ImportDeclaration d = (ImportDeclaration) node; int startOffset = d.astParts().first().getPosition().getStart(); Position start = location.getStart(); int startColumn = start.getColumn(); int startLine = start.getLine(); start = new DefaultPosition(startLine, startColumn + startOffset - start.getOffset(), startOffset); int fqcnLength = fqcn.length(); Position end = new DefaultPosition(startLine, start.getColumn() + fqcnLength, start.getOffset() + fqcnLength); location = Location.create(location.getFile(), start, end); } String message = String.format( "Field requires API level %1$d (current min is %2$d): `%3$s`", api, minSdk, fqcn); LintDriver driver = mContext.getDriver(); if (driver.isSuppressed(mContext, INLINED, node)) { return true; } // Also allow to suppress these issues with NewApi, since some // fields used to get identified that way if (driver.isSuppressed(mContext, UNSUPPORTED, node)) { return true; } // We can't report the issue right away; we don't yet know if // this is an actual inlined (static primitive or String) yet. // So just make a note of it, and report these after the project // checking has finished; any fields that aren't inlined will be // cleared when they're noticed by the class check. if (mPendingFields == null) { mPendingFields = Maps.newHashMapWithExpectedSize(20); } List<Pair<String, Location>> list = mPendingFields.get(fqcn); if (list == null) { list = new ArrayList<Pair<String, Location>>(); mPendingFields.put(fqcn, list); } else { // See if this location already exists. This can happen if // we have multiple references to an inlined field on the same // line. Since the class file only gives us line information, we // can't distinguish between these in the client as separate usages, // so they end up being identical errors. for (Pair<String, Location> pair : list) { Location existingLocation = pair.getSecond(); if (location.getFile().equals(existingLocation.getFile())) { Position start = location.getStart(); Position existingStart = existingLocation.getStart(); if (start != null && existingStart != null && start.getLine() == existingStart.getLine()) { return true; } } } } list.add(Pair.of(message, location)); } return true; } return false; } /** * Returns the minimum SDK to use according to the given AST node, or null * if no {@code TargetApi} annotations were found * * @return the API level to use for this node, or -1 */ public int getLocalMinSdk(@Nullable lombok.ast.Node scope) { while (scope != null) { Class<? extends lombok.ast.Node> type = scope.getClass(); // The Lombok AST uses a flat hierarchy of node type implementation classes // so no need to do instanceof stuff here. if (type == VariableDefinition.class) { // Variable VariableDefinition declaration = (VariableDefinition) scope; int targetApi = getTargetApi(declaration.astModifiers()); if (targetApi != -1) { return targetApi; } } else if (type == MethodDeclaration.class) { // Method // Look for annotations on the method MethodDeclaration declaration = (MethodDeclaration) scope; int targetApi = getTargetApi(declaration.astModifiers()); if (targetApi != -1) { return targetApi; } } else if (type == ConstructorDeclaration.class) { // Constructor // Look for annotations on the method ConstructorDeclaration declaration = (ConstructorDeclaration) scope; int targetApi = getTargetApi(declaration.astModifiers()); if (targetApi != -1) { return targetApi; } } else if (type == ClassDeclaration.class) { // Class ClassDeclaration declaration = (ClassDeclaration) scope; int targetApi = getTargetApi(declaration.astModifiers()); if (targetApi != -1) { return targetApi; } } scope = scope.getParent(); } return -1; } } /** * Returns the API level for the given AST node if specified with * an {@code @TargetApi} annotation. * * @param modifiers the modifier to check * @return the target API level, or -1 if not specified */ public static int getTargetApi(@Nullable Modifiers modifiers) { if (modifiers == null) { return -1; } StrictListAccessor<Annotation, Modifiers> annotations = modifiers.astAnnotations(); if (annotations == null) { return -1; } for (Annotation annotation : annotations) { TypeReference t = annotation.astAnnotationTypeReference(); String typeName = t.getTypeName(); if (typeName.endsWith(TARGET_API)) { StrictListAccessor<AnnotationElement, Annotation> values = annotation.astElements(); if (values != null) { for (AnnotationElement element : values) { AnnotationValue valueNode = element.astValue(); if (valueNode == null) { continue; } if (valueNode instanceof IntegralLiteral) { IntegralLiteral literal = (IntegralLiteral) valueNode; return literal.astIntValue(); } else if (valueNode instanceof StringLiteral) { String value = ((StringLiteral) valueNode).astValue(); return SdkVersionInfo.getApiByBuildCode(value, true); } else if (valueNode instanceof Select) { Select select = (Select) valueNode; String codename = select.astIdentifier().astValue(); return SdkVersionInfo.getApiByBuildCode(codename, true); } else if (valueNode instanceof VariableReference) { VariableReference reference = (VariableReference) valueNode; String codename = reference.astIdentifier().astValue(); return SdkVersionInfo.getApiByBuildCode(codename, true); } } } } } return -1; } public static int getRequiredVersion(@NonNull Issue issue, @NonNull String errorMessage, @NonNull TextFormat format) { errorMessage = format.toText(errorMessage); if (issue == UNSUPPORTED || issue == INLINED) { Pattern pattern = Pattern.compile("\\s(\\d+)\\s"); //$NON-NLS-1$ Matcher matcher = pattern.matcher(errorMessage); if (matcher.find()) { return Integer.parseInt(matcher.group(1)); } } return -1; } private static boolean isWithinSdkConditional( @NonNull ClassContext context, @NonNull ClassNode classNode, @NonNull MethodNode method, @NonNull AbstractInsnNode call, int requiredApi) { assert requiredApi != -1; if (!containsSimpleSdkCheck(method)) { return false; } try { // Search in the control graph, from beginning, up to the target call // node, to see if it's reachable. The call graph is constructed in a // special way: we include all control flow edges, *except* those that // are satisfied by a SDK_INT version check (where the operand is a version // that is at least as high as the one needed for the given call). // // If we can reach the call, that means that there is a way this call // can be reached on some versions, and lint should flag the call/field lookup. // // // Let's say you have code like this: // if (SDK_INT >= LOLLIPOP) { // // Call // return property.hasAdjacentMapping(); // } // ... // // The compiler will turn this into the following byte code: // // 0: getstatic #3; //Field android/os/Build$VERSION.SDK_INT:I // 3: bipush 21 // 5: if_icmple 17 // 8: aload_1 // 9: invokeinterface #4, 1; //InterfaceMethod // android/view/ViewDebug$ExportedProperty.hasAdjacentMapping:()Z // 14: ifeq 17 // 17: ... code after if loop // // When the call graph is constructed, for an if branch we're called twice; once // where the target is the next instruction (the one taken if byte code check is false) // and one to the jump label (the one taken if the byte code condition is true). // // Notice how at the byte code level, the logic is reversed: the >= instruction // is turned into "<" and we jump to the code *after* the if clause; otherwise // it will just fall through. Therefore, if we take a byte code branch, that means // that the SDK check was *not* satisfied, and conversely, the target call is reachable // if we don't take the branch. // // Therefore, when we build the call graph, we will add call graph nodes for an // if check if : // (1) it is some other comparison than <, <= or !=. // (2) if the byte code comparison check is *not* satisfied, this means that the the // SDK check was successful and that the call graph should only include // the jump edge // (3) all other edges are added // // With a flow control graph like that, we can determine whether a target call // is guarded by a given SDK check: that will be the case if we cannot reach // the target call in the call graph ApiCheckGraph graph = new ApiCheckGraph(requiredApi); ControlFlowGraph.create(graph, classNode, method); // Note: To debug unit tests, you may want to for example do // ControlFlowGraph.Node callNode = graph.getNode(call); // Set<ControlFlowGraph.Node> highlight = Sets.newHashSet(callNode); // Files.write(graph.toDot(highlight), new File("/tmp/graph.gv"), Charsets.UTF_8); // This will generate a graphviz file you can visualize with the "dot" utility AbstractInsnNode first = method.instructions.get(0); return !graph.isConnected(first, call); } catch (AnalyzerException e) { context.log(e, null); } return false; } private static boolean containsSimpleSdkCheck(@NonNull MethodNode method) { // Look for a compiled version of "if (Build.VERSION.SDK_INT op N) {" InsnList nodes = method.instructions; for (int i = 0, n = nodes.size(); i < n; i++) { AbstractInsnNode instruction = nodes.get(i); if (isSdkVersionLookup(instruction)) { AbstractInsnNode bipush = getNextInstruction(instruction); if (bipush != null && bipush.getOpcode() == Opcodes.BIPUSH) { AbstractInsnNode ifNode = getNextInstruction(bipush); if (ifNode != null && ifNode.getType() == AbstractInsnNode.JUMP_INSN) { return true; } } } } return false; } private static boolean isSdkVersionLookup(@NonNull AbstractInsnNode instruction) { if (instruction.getOpcode() == Opcodes.GETSTATIC) { FieldInsnNode fieldNode = (FieldInsnNode) instruction; return (SDK_INT.equals(fieldNode.name) && ANDROID_OS_BUILD_VERSION.equals(fieldNode.owner)); } return false; } /** * Control flow graph which skips control flow edges that check * a given SDK_VERSION requirement that is not met by a given call */ private static class ApiCheckGraph extends ControlFlowGraph { private final int mRequiredApi; public ApiCheckGraph(int requiredApi) { mRequiredApi = requiredApi; } @Override protected void add(@NonNull AbstractInsnNode from, @NonNull AbstractInsnNode to) { if (from.getType() == AbstractInsnNode.JUMP_INSN && from.getPrevious() != null && from.getPrevious().getType() == AbstractInsnNode.INT_INSN) { IntInsnNode intNode = (IntInsnNode) from.getPrevious(); if (intNode.getPrevious() != null && isSdkVersionLookup(intNode.getPrevious())) { JumpInsnNode jumpNode = (JumpInsnNode) from; int api = intNode.operand; boolean isJumpEdge = to == jumpNode.label; boolean includeEdge; switch (from.getOpcode()) { case Opcodes.IF_ICMPNE: includeEdge = api < mRequiredApi || isJumpEdge; break; case Opcodes.IF_ICMPLE: includeEdge = api < mRequiredApi - 1 || isJumpEdge; break; case Opcodes.IF_ICMPLT: includeEdge = api < mRequiredApi || isJumpEdge; break; case Opcodes.IF_ICMPGE: includeEdge = api < mRequiredApi || !isJumpEdge; break; case Opcodes.IF_ICMPGT: includeEdge = api < mRequiredApi - 1 || !isJumpEdge; break; default: // unexpected comparison for int API level includeEdge = true; } if (!includeEdge) { return; } } } super.add(from, to); } } }