blob: b28df292e711745284c561f342f578c23e7c07a0 [file] [log] [blame]
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.lint.checks;
import static com.android.SdkConstants.CLASS_CONTEXT;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.tools.lint.client.api.JavaParser.ResolvedClass;
import com.android.tools.lint.client.api.JavaParser.ResolvedField;
import com.android.tools.lint.client.api.JavaParser.ResolvedMethod;
import com.android.tools.lint.client.api.JavaParser.ResolvedNode;
import com.android.tools.lint.client.api.JavaParser.ResolvedVariable;
import com.android.tools.lint.client.api.JavaParser.TypeDescriptor;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Detector;
import com.android.tools.lint.detector.api.Detector.JavaScanner;
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.Location;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.google.common.collect.Lists;
import java.util.Arrays;
import java.util.List;
import lombok.ast.AstVisitor;
import lombok.ast.BinaryExpression;
import lombok.ast.BinaryOperator;
import lombok.ast.ConstructorInvocation;
import lombok.ast.Expression;
import lombok.ast.ForwardingAstVisitor;
import lombok.ast.MethodInvocation;
import lombok.ast.Node;
import lombok.ast.Return;
import lombok.ast.StrictListAccessor;
import lombok.ast.VariableDefinitionEntry;
import lombok.ast.VariableReference;
/**
* Checks for missing {@code recycle} calls on resources that encourage it, and
* for missing {@code commit} calls on FragmentTransactions, etc.
*/
public class CleanupDetector extends Detector implements JavaScanner {
private static final Implementation IMPLEMENTATION = new Implementation(
CleanupDetector.class,
Scope.JAVA_FILE_SCOPE);
/** Problems with missing recycle calls */
public static final Issue RECYCLE_RESOURCE = Issue.create(
"Recycle", //$NON-NLS-1$
"Missing `recycle()` calls",
"Many resources, such as TypedArrays, VelocityTrackers, etc., " +
"should be recycled (with a `recycle()` call) after use. This lint check looks " +
"for missing `recycle()` calls.",
Category.PERFORMANCE,
7,
Severity.WARNING,
IMPLEMENTATION);
/** Problems with missing commit calls. */
public static final Issue COMMIT_FRAGMENT = Issue.create(
"CommitTransaction", //$NON-NLS-1$
"Missing `commit()` calls",
"After creating a `FragmentTransaction`, you typically need to commit it as well",
Category.CORRECTNESS,
7,
Severity.WARNING,
IMPLEMENTATION);
// Target method names
private static final String RECYCLE = "recycle"; //$NON-NLS-1$
private static final String RELEASE = "release"; //$NON-NLS-1$
private static final String OBTAIN = "obtain"; //$NON-NLS-1$
private static final String SHOW = "show"; //$NON-NLS-1$
private static final String ACQUIRE_CPC = "acquireContentProviderClient"; //$NON-NLS-1$
private static final String OBTAIN_NO_HISTORY = "obtainNoHistory"; //$NON-NLS-1$
private static final String OBTAIN_ATTRIBUTES = "obtainAttributes"; //$NON-NLS-1$
private static final String OBTAIN_TYPED_ARRAY = "obtainTypedArray"; //$NON-NLS-1$
private static final String OBTAIN_STYLED_ATTRIBUTES = "obtainStyledAttributes"; //$NON-NLS-1$
private static final String BEGIN_TRANSACTION = "beginTransaction"; //$NON-NLS-1$
private static final String COMMIT = "commit"; //$NON-NLS-1$
private static final String COMMIT_ALLOWING_LOSS = "commitAllowingStateLoss"; //$NON-NLS-1$
private static final String QUERY = "query"; //$NON-NLS-1$
private static final String RAW_QUERY = "rawQuery"; //$NON-NLS-1$
private static final String QUERY_WITH_FACTORY = "queryWithFactory"; //$NON-NLS-1$
private static final String RAW_QUERY_WITH_FACTORY = "rawQueryWithFactory"; //$NON-NLS-1$
private static final String CLOSE = "close"; //$NON-NLS-1$
private static final String MOTION_EVENT_CLS = "android.view.MotionEvent"; //$NON-NLS-1$
private static final String RESOURCES_CLS = "android.content.res.Resources"; //$NON-NLS-1$
private static final String PARCEL_CLS = "android.os.Parcel"; //$NON-NLS-1$
private static final String TYPED_ARRAY_CLS = "android.content.res.TypedArray"; //$NON-NLS-1$
private static final String VELOCITY_TRACKER_CLS = "android.view.VelocityTracker";//$NON-NLS-1$
private static final String DIALOG_FRAGMENT = "android.app.DialogFragment"; //$NON-NLS-1$
private static final String DIALOG_V4_FRAGMENT =
"android.support.v4.app.DialogFragment"; //$NON-NLS-1$
private static final String FRAGMENT_MANAGER_CLS = "android.app.FragmentManager"; //$NON-NLS-1$
private static final String FRAGMENT_MANAGER_V4_CLS =
"android.support.v4.app.FragmentManager"; //$NON-NLS-1$
private static final String FRAGMENT_TRANSACTION_CLS =
"android.app.FragmentTransaction"; //$NON-NLS-1$
private static final String FRAGMENT_TRANSACTION_V4_CLS =
"android.support.v4.app.FragmentTransaction"; //$NON-NLS-1$
public static final String SURFACE_CLS = "android.view.Surface";
public static final String SURFACE_TEXTURE_CLS = "android.graphics.SurfaceTexture";
public static final String CONTENT_PROVIDER_CLIENT_CLS
= "android.content.ContentProviderClient";
public static final String CONTENT_RESOLVER_CLS = "android.content.ContentResolver";
public static final String CONTENT_PROVIDER_CLS = "android.content.ContentProvider";
@SuppressWarnings("SpellCheckingInspection")
public static final String SQLITE_DATABASE_CLS = "android.database.sqlite.SQLiteDatabase";
public static final String CURSOR_CLS = "android.database.Cursor";
/** Constructs a new {@link CleanupDetector} */
public CleanupDetector() {
}
// ---- Implements JavaScanner ----
@Nullable
@Override
public List<String> getApplicableMethodNames() {
return Arrays.asList(
// FragmentManager commit check
BEGIN_TRANSACTION,
// Recycle check
OBTAIN, OBTAIN_NO_HISTORY,
OBTAIN_STYLED_ATTRIBUTES,
OBTAIN_ATTRIBUTES,
OBTAIN_TYPED_ARRAY,
// Release check
ACQUIRE_CPC,
// Cursor close check
QUERY, RAW_QUERY, QUERY_WITH_FACTORY, RAW_QUERY_WITH_FACTORY
);
}
@Nullable
@Override
public List<String> getApplicableConstructorTypes() {
return Arrays.asList(SURFACE_TEXTURE_CLS, SURFACE_CLS);
}
@Override
public void visitMethod(@NonNull JavaContext context, @Nullable AstVisitor visitor,
@NonNull MethodInvocation node) {
String name = node.astName().astValue();
if (BEGIN_TRANSACTION.equals(name)) {
checkTransactionCommits(context, node);
} else {
checkResourceRecycled(context, node, name);
}
}
@Override
public void visitConstructor(@NonNull JavaContext context, @Nullable AstVisitor visitor,
@NonNull ConstructorInvocation node, @NonNull ResolvedMethod constructor) {
checkRecycled(context, node, constructor.getContainingClass().getSignature(), RELEASE);
}
private static void checkResourceRecycled(@NonNull JavaContext context,
@NonNull MethodInvocation node, @NonNull String name) {
// Recycle detector
ResolvedNode resolved = context.resolve(node);
if (!(resolved instanceof ResolvedMethod)) {
return;
}
ResolvedMethod method = (ResolvedMethod) resolved;
ResolvedClass containingClass = method.getContainingClass();
if ((OBTAIN.equals(name) || OBTAIN_NO_HISTORY.equals(name)) &&
containingClass.isSubclassOf(MOTION_EVENT_CLS, false)) {
checkRecycled(context, node, MOTION_EVENT_CLS, RECYCLE);
} else if (OBTAIN.equals(name) && containingClass.isSubclassOf(PARCEL_CLS, false)) {
checkRecycled(context, node, PARCEL_CLS, RECYCLE);
} else if (OBTAIN.equals(name) &&
containingClass.isSubclassOf(VELOCITY_TRACKER_CLS, false)) {
checkRecycled(context, node, VELOCITY_TRACKER_CLS, RECYCLE);
} else if ((OBTAIN_STYLED_ATTRIBUTES.equals(name)
|| OBTAIN_ATTRIBUTES.equals(name)
|| OBTAIN_TYPED_ARRAY.equals(name)) &&
(containingClass.isSubclassOf(CLASS_CONTEXT, false) ||
containingClass.isSubclassOf(RESOURCES_CLS, false))) {
TypeDescriptor returnType = method.getReturnType();
if (returnType != null && returnType.matchesSignature(TYPED_ARRAY_CLS)) {
checkRecycled(context, node, TYPED_ARRAY_CLS, RECYCLE);
}
} else if (ACQUIRE_CPC.equals(name) && containingClass.isSubclassOf(
CONTENT_RESOLVER_CLS, false)) {
checkRecycled(context, node, CONTENT_PROVIDER_CLIENT_CLS, RELEASE);
} else if ((QUERY.equals(name)
|| RAW_QUERY.equals(name)
|| QUERY_WITH_FACTORY.equals(name)
|| RAW_QUERY_WITH_FACTORY.equals(name))
&& (containingClass.isSubclassOf(SQLITE_DATABASE_CLS, false) ||
containingClass.isSubclassOf(CONTENT_RESOLVER_CLS, false) ||
containingClass.isSubclassOf(CONTENT_PROVIDER_CLS, false) ||
containingClass.isSubclassOf(CONTENT_PROVIDER_CLIENT_CLS, false))) {
// Other potential cursors-returning methods that should be tracked:
// android.app.DownloadManager#query
// android.content.ContentProviderClient#query
// android.content.ContentResolver#query
// android.database.sqlite.SQLiteQueryBuilder#query
// android.provider.Browser#getAllBookmarks
// android.provider.Browser#getAllVisitedUrls
// android.provider.DocumentsProvider#queryChildDocuments
// android.provider.DocumentsProvider#qqueryDocument
// android.provider.DocumentsProvider#queryRecentDocuments
// android.provider.DocumentsProvider#queryRoots
// android.provider.DocumentsProvider#querySearchDocuments
// android.provider.MediaStore$Images$Media#query
// android.widget.FilterQueryProvider#runQuery
checkRecycled(context, node, CURSOR_CLS, CLOSE);
}
}
private static void checkRecycled(@NonNull final JavaContext context, @NonNull Node node,
@NonNull final String recycleType, @NonNull final String recycleName) {
ResolvedVariable boundVariable = getVariable(context, node);
if (boundVariable == null) {
return;
}
Node method = JavaContext.findSurroundingMethod(node);
if (method == null) {
return;
}
FinishVisitor visitor = new FinishVisitor(context, boundVariable) {
@Override
protected boolean isCleanupCall(@NonNull MethodInvocation call) {
String methodName = call.astName().astValue();
if (!recycleName.equals(methodName)) {
return false;
}
ResolvedNode resolved = mContext.resolve(call);
if (resolved instanceof ResolvedMethod) {
ResolvedClass containingClass = ((ResolvedMethod) resolved).getContainingClass();
if (containingClass.isSubclassOf(recycleType, false)) {
// Yes, called the right recycle() method; now make sure
// we're calling it on the right variable
Expression operand = call.astOperand();
if (operand != null) {
resolved = mContext.resolve(operand);
//noinspection SuspiciousMethodCalls
if (resolved != null && mVariables.contains(resolved)) {
return true;
}
}
}
}
return false;
}
};
method.accept(visitor);
if (visitor.isCleanedUp() || visitor.variableEscapes()) {
return;
}
String className = recycleType.substring(recycleType.lastIndexOf('.') + 1);
String message;
if (RECYCLE.equals(recycleName)) {
message = String.format(
"This `%1$s` should be recycled after use with `#recycle()`", className);
} else {
message = String.format(
"This `%1$s` should be freed up after use with `#%2$s()`", className,
recycleName);
}
Node locationNode = node instanceof MethodInvocation ?
((MethodInvocation) node).astName() : node;
Location location = context.getLocation(locationNode);
context.report(RECYCLE_RESOURCE, node, location, message);
}
private static boolean checkTransactionCommits(@NonNull JavaContext context,
@NonNull MethodInvocation node) {
if (isBeginTransaction(context, node)) {
ResolvedVariable boundVariable = getVariable(context, node);
if (boundVariable == null && isCommittedInChainedCalls(context, node)) {
return true;
}
if (boundVariable != null) {
Node method = JavaContext.findSurroundingMethod(node);
if (method == null) {
return true;
}
FinishVisitor commitVisitor = new FinishVisitor(context, boundVariable) {
@Override
protected boolean isCleanupCall(@NonNull MethodInvocation call) {
if (isTransactionCommitMethodCall(mContext, call)) {
Expression operand = call.astOperand();
if (operand != null) {
ResolvedNode resolved = mContext.resolve(operand);
//noinspection SuspiciousMethodCalls
if (resolved != null && mVariables.contains(resolved)) {
return true;
} else if (resolved instanceof ResolvedMethod
&& operand instanceof MethodInvocation
&& isCommittedInChainedCalls(mContext,(MethodInvocation) operand)) {
// Check that the target of the committed chains is the
// right variable!
while (operand instanceof MethodInvocation) {
operand = ((MethodInvocation)operand).astOperand();
}
if (operand instanceof VariableReference) {
resolved = mContext.resolve(operand);
//noinspection SuspiciousMethodCalls
if (resolved != null && mVariables.contains(resolved)) {
return true;
}
}
}
}
} else if (isShowFragmentMethodCall(mContext, call)) {
StrictListAccessor<Expression, MethodInvocation> arguments =
call.astArguments();
if (arguments.size() == 2) {
Expression first = arguments.first();
ResolvedNode resolved = mContext.resolve(first);
//noinspection SuspiciousMethodCalls
if (resolved != null && mVariables.contains(resolved)) {
return true;
}
}
}
return false;
}
};
method.accept(commitVisitor);
if (commitVisitor.isCleanedUp() || commitVisitor.variableEscapes()) {
return true;
}
}
String message = "This transaction should be completed with a `commit()` call";
context.report(COMMIT_FRAGMENT, node, context.getLocation(node.astName()),
message);
}
return false;
}
private static boolean isCommittedInChainedCalls(@NonNull JavaContext context,
@NonNull MethodInvocation node) {
// Look for chained calls since the FragmentManager methods all return "this"
// to allow constructor chaining, e.g.
// getFragmentManager().beginTransaction().addToBackStack("test")
// .disallowAddToBackStack().hide(mFragment2).setBreadCrumbShortTitle("test")
// .show(mFragment2).setCustomAnimations(0, 0).commit();
Node parent = node.getParent();
while (parent instanceof MethodInvocation) {
MethodInvocation methodInvocation = (MethodInvocation) parent;
if (isTransactionCommitMethodCall(context, methodInvocation)
|| isShowFragmentMethodCall(context, methodInvocation)) {
return true;
}
parent = parent.getParent();
}
return false;
}
private static boolean isTransactionCommitMethodCall(@NonNull JavaContext context,
@NonNull MethodInvocation call) {
String methodName = call.astName().astValue();
return (COMMIT.equals(methodName) || COMMIT_ALLOWING_LOSS.equals(methodName)) &&
isMethodOnFragmentClass(context, call,
FRAGMENT_TRANSACTION_CLS,
FRAGMENT_TRANSACTION_V4_CLS);
}
private static boolean isShowFragmentMethodCall(@NonNull JavaContext context,
@NonNull MethodInvocation call) {
String methodName = call.astName().astValue();
return SHOW.equals(methodName)
&& isMethodOnFragmentClass(context, call,
DIALOG_FRAGMENT, DIALOG_V4_FRAGMENT);
}
private static boolean isMethodOnFragmentClass(
@NonNull JavaContext context,
@NonNull MethodInvocation call,
@NonNull String fragmentClass,
@NonNull String v4FragmentClass) {
ResolvedNode resolved = context.resolve(call);
if (resolved instanceof ResolvedMethod) {
ResolvedClass containingClass = ((ResolvedMethod) resolved).getContainingClass();
return containingClass.isSubclassOf(fragmentClass, false) ||
containingClass.isSubclassOf(v4FragmentClass, false);
}
return false;
}
@Nullable
public static ResolvedVariable getVariable(@NonNull JavaContext context,
@NonNull Node expression) {
Node parent = expression.getParent();
if (parent instanceof BinaryExpression) {
BinaryExpression binaryExpression = (BinaryExpression) parent;
if (binaryExpression.astOperator() == BinaryOperator.ASSIGN) {
Expression lhs = binaryExpression.astLeft();
ResolvedNode resolved = context.resolve(lhs);
if (resolved instanceof ResolvedVariable) {
return (ResolvedVariable) resolved;
}
}
} else if (parent instanceof VariableDefinitionEntry) {
ResolvedNode resolved = context.resolve(parent);
if (resolved instanceof ResolvedVariable) {
return (ResolvedVariable) resolved;
}
}
return null;
}
private static boolean isBeginTransaction(@NonNull JavaContext context,
@NonNull MethodInvocation node) {
String methodName = node.astName().astValue();
assert methodName.equals(BEGIN_TRANSACTION) : methodName;
if (BEGIN_TRANSACTION.equals(methodName)) {
ResolvedNode resolved = context.resolve(node);
if (resolved instanceof ResolvedMethod) {
ResolvedMethod method = (ResolvedMethod) resolved;
ResolvedClass containingClass = method.getContainingClass();
if (containingClass.isSubclassOf(FRAGMENT_MANAGER_CLS, false)
|| containingClass.isSubclassOf(FRAGMENT_MANAGER_V4_CLS,
false)) {
return true;
}
}
}
return false;
}
/**
* Visitor which checks whether an operation is "finished"; in the case
* of a FragmentTransaction we're looking for a "commit" call; in the
* case of a TypedArray we're looking for a "recycle", call, in the
* case of a database cursor we're looking for a "close" call, etc.
*/
private abstract static class FinishVisitor extends ForwardingAstVisitor {
protected final JavaContext mContext;
protected final List<ResolvedVariable> mVariables;
private boolean mContainsCleanup;
private boolean mEscapes;
public FinishVisitor(JavaContext context, @NonNull ResolvedVariable variable) {
mContext = context;
mVariables = Lists.newArrayList(variable);
}
public boolean isCleanedUp() {
return mContainsCleanup;
}
public boolean variableEscapes() {
return mEscapes;
}
@Override
public boolean visitNode(Node node) {
return mContainsCleanup || super.visitNode(node);
}
protected abstract boolean isCleanupCall(@NonNull MethodInvocation call);
@Override
public boolean visitMethodInvocation(MethodInvocation call) {
if (mContainsCleanup) {
return true;
}
super.visitMethodInvocation(call);
// Look for escapes
if (!mEscapes) {
for (Expression expression : call.astArguments()) {
if (expression instanceof VariableReference) {
ResolvedNode resolved = mContext.resolve(expression);
//noinspection SuspiciousMethodCalls
if (resolved != null && mVariables.contains(resolved)) {
mEscapes = true;
// Special case: MotionEvent.obtain(MotionEvent): passing in an
// event here does not recycle the event, and we also know it
// doesn't escape
if (OBTAIN.equals(call.astName().astValue())) {
ResolvedNode r = mContext.resolve(call);
if (r instanceof ResolvedMethod) {
ResolvedMethod method = (ResolvedMethod) r;
ResolvedClass cls = method.getContainingClass();
if (cls.matches(MOTION_EVENT_CLS)) {
mEscapes = false;
}
}
}
}
}
}
}
if (isCleanupCall(call)) {
mContainsCleanup = true;
return true;
} else {
return false;
}
}
@Override
public boolean visitVariableDefinitionEntry(VariableDefinitionEntry node) {
Expression initializer = node.astInitializer();
if (initializer instanceof VariableReference) {
ResolvedNode resolved = mContext.resolve(initializer);
//noinspection SuspiciousMethodCalls
if (resolved != null && mVariables.contains(resolved)) {
ResolvedNode resolvedVariable = mContext.resolve(node);
if (resolvedVariable instanceof ResolvedVariable) {
ResolvedVariable variable = (ResolvedVariable) resolvedVariable;
mVariables.add(variable);
} else if (resolvedVariable instanceof ResolvedField) {
mEscapes = true;
}
}
}
return super.visitVariableDefinitionEntry(node);
}
@Override
public boolean visitBinaryExpression(BinaryExpression node) {
if (node.astOperator() == BinaryOperator.ASSIGN) {
Expression rhs = node.astRight();
if (rhs instanceof VariableReference) {
ResolvedNode resolved = mContext.resolve(rhs);
//noinspection SuspiciousMethodCalls
if (resolved != null && mVariables.contains(resolved)) {
ResolvedNode resolvedLhs = mContext.resolve(node.astLeft());
if (resolvedLhs instanceof ResolvedVariable) {
ResolvedVariable variable = (ResolvedVariable) resolvedLhs;
mVariables.add(variable);
} else if (resolvedLhs instanceof ResolvedField) {
mEscapes = true;
}
}
}
}
return super.visitBinaryExpression(node);
}
@Override
public boolean visitReturn(Return node) {
Expression value = node.astValue();
if (value instanceof VariableReference) {
ResolvedNode resolved = mContext.resolve(value);
//noinspection SuspiciousMethodCalls
if (resolved != null && mVariables.contains(resolved)) {
mEscapes = true;
}
}
return super.visitReturn(node);
}
}
}