com.android.tools.lint.checks.CleanupDetector Maven / Gradle / Ivy
/*
* 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 com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.ClassContext;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Detector;
import com.android.tools.lint.detector.api.Detector.ClassScanner;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.analysis.Analyzer;
import org.objectweb.asm.tree.analysis.AnalyzerException;
import org.objectweb.asm.tree.analysis.BasicValue;
import org.objectweb.asm.tree.analysis.Frame;
import org.objectweb.asm.tree.analysis.Interpreter;
import org.objectweb.asm.tree.analysis.Value;
import java.util.Arrays;
import java.util.List;
/**
* 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 ClassScanner {
private static final Implementation IMPLEMENTATION = new Implementation(
CleanupDetector.class,
Scope.CLASS_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 OBTAIN = "obtain"; //$NON-NLS-1$
private static final String SHOW = "show"; //$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$
// Target owners
private static final String VELOCITY_TRACKER_CLS = "android/view/VelocityTracker";//$NON-NLS-1$
private static final String TYPED_ARRAY_CLS = "android/content/res/TypedArray"; //$NON-NLS-1$
private static final String CONTEXT_CLS = "android/content/Context"; //$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 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$
private static final String DIALOG_FRAGMENT_SHOW_DESC =
"(Landroid/app/FragmentTransaction;Ljava/lang/String;)I"; //$NON-NLS-1$
private static final String DIALOG_FRAGMENT_SHOW_V4_DESC =
"(Landroid/support/v4/app/FragmentTransaction;Ljava/lang/String;)I"; //$NON-NLS-1$
// Target description signatures
private static final String TYPED_ARRAY_SIG = "Landroid/content/res/TypedArray;"; //$NON-NLS-1$
private boolean mObtainsTypedArray;
private boolean mRecyclesTypedArray;
private boolean mObtainsTracker;
private boolean mRecyclesTracker;
private boolean mObtainsMotionEvent;
private boolean mRecyclesMotionEvent;
private boolean mObtainsParcel;
private boolean mRecyclesParcel;
private boolean mObtainsTransaction;
private boolean mCommitsTransaction;
/** Constructs a new {@link CleanupDetector} */
public CleanupDetector() {
}
@Override
public void afterCheckProject(@NonNull Context context) {
int phase = context.getDriver().getPhase();
if (phase == 1) {
if (mObtainsTypedArray && !mRecyclesTypedArray
|| mObtainsTracker && !mRecyclesTracker
|| mObtainsParcel && !mRecyclesParcel
|| mObtainsMotionEvent && !mRecyclesMotionEvent
|| mObtainsTransaction && !mCommitsTransaction) {
context.getDriver().requestRepeat(this, Scope.CLASS_FILE_SCOPE);
}
}
}
// ---- Implements ClassScanner ----
@Override
@Nullable
public List getApplicableCallNames() {
return Arrays.asList(
RECYCLE,
OBTAIN_STYLED_ATTRIBUTES,
OBTAIN,
OBTAIN_ATTRIBUTES,
OBTAIN_TYPED_ARRAY,
OBTAIN_NO_HISTORY,
BEGIN_TRANSACTION,
COMMIT,
COMMIT_ALLOWING_LOSS,
SHOW
);
}
@Override
public void checkCall(
@NonNull ClassContext context,
@NonNull ClassNode classNode,
@NonNull MethodNode method,
@NonNull MethodInsnNode call) {
String name = call.name;
String owner = call.owner;
String desc = call.desc;
int phase = context.getDriver().getPhase();
if (SHOW.equals(name)) {
if (desc.equals(DIALOG_FRAGMENT_SHOW_DESC)
|| desc.equals(DIALOG_FRAGMENT_SHOW_V4_DESC)) {
mCommitsTransaction = true;
}
} else if (RECYCLE.equals(name) && desc.equals("()V")) { //$NON-NLS-1$
if (owner.equals(TYPED_ARRAY_CLS)) {
mRecyclesTypedArray = true;
} else if (owner.equals(VELOCITY_TRACKER_CLS)) {
mRecyclesTracker = true;
} else if (owner.equals(MOTION_EVENT_CLS)) {
mRecyclesMotionEvent = true;
} else if (owner.equals(PARCEL_CLS)) {
mRecyclesParcel = true;
}
} else if ((COMMIT.equals(name) || COMMIT_ALLOWING_LOSS.equals(name))
&& desc.equals("()I")) { //$NON-NLS-1$
if (owner.equals(FRAGMENT_TRANSACTION_CLS)
|| owner.equals(FRAGMENT_TRANSACTION_V4_CLS)) {
mCommitsTransaction = true;
}
} else if (owner.equals(MOTION_EVENT_CLS)) {
if (OBTAIN.equals(name) || OBTAIN_NO_HISTORY.equals(name)) {
mObtainsMotionEvent = true;
if (phase == 2 && !mRecyclesMotionEvent) {
context.report(RECYCLE_RESOURCE, method, call, context.getLocation(call),
getErrorMessage(MOTION_EVENT_CLS));
} else if (phase == 1
&& checkMethodFlow(context, classNode, method, call, MOTION_EVENT_CLS)) {
// Already reported error above; don't do global check
mRecyclesMotionEvent = true;
}
}
} else if (OBTAIN.equals(name)) {
if (owner.equals(VELOCITY_TRACKER_CLS)) {
mObtainsTracker = true;
if (phase == 2 && !mRecyclesTracker) {
context.report(RECYCLE_RESOURCE, method, call, context.getLocation(call),
getErrorMessage(VELOCITY_TRACKER_CLS));
}
} else if (owner.equals(PARCEL_CLS)) {
mObtainsParcel = true;
if (phase == 2 && !mRecyclesParcel) {
context.report(RECYCLE_RESOURCE, method, call, context.getLocation(call),
getErrorMessage(PARCEL_CLS));
} else if (phase == 1
&& checkMethodFlow(context, classNode, method, call, PARCEL_CLS)) {
// Already reported error above; don't do global check
mRecyclesParcel = true;
}
}
} else if (OBTAIN_STYLED_ATTRIBUTES.equals(name)
|| OBTAIN_ATTRIBUTES.equals(name)
|| OBTAIN_TYPED_ARRAY.equals(name)) {
if ((owner.equals(CONTEXT_CLS) || owner.equals(RESOURCES_CLS))
&& desc.endsWith(TYPED_ARRAY_SIG)) {
mObtainsTypedArray = true;
if (phase == 2 && !mRecyclesTypedArray) {
context.report(RECYCLE_RESOURCE, method, call, context.getLocation(call),
getErrorMessage(TYPED_ARRAY_CLS));
} else if (phase == 1
&& checkMethodFlow(context, classNode, method, call, TYPED_ARRAY_CLS)) {
// Already reported error above; don't do global check
mRecyclesTypedArray = true;
}
}
} else if (BEGIN_TRANSACTION.equals(name)
&& (owner.equals(FRAGMENT_MANAGER_CLS) || owner.equals(FRAGMENT_MANAGER_V4_CLS))) {
mObtainsTransaction = true;
if (phase == 2 && !mCommitsTransaction) {
context.report(COMMIT_FRAGMENT, method, call, context.getLocation(call),
getErrorMessage(owner.equals(FRAGMENT_MANAGER_CLS)
? FRAGMENT_TRANSACTION_CLS : FRAGMENT_TRANSACTION_V4_CLS));
} else if (phase == 1
&& checkMethodFlow(context, classNode, method, call,
owner.equals(FRAGMENT_MANAGER_CLS)
? FRAGMENT_TRANSACTION_CLS : FRAGMENT_TRANSACTION_V4_CLS)) {
// Already reported error above; don't do global check
mCommitsTransaction = true;
}
}
}
/** Computes an error message for a missing recycle of the given type */
private static String getErrorMessage(String owner) {
if (FRAGMENT_TRANSACTION_CLS.equals(owner) || FRAGMENT_TRANSACTION_V4_CLS.equals(owner)) {
return "This transaction should be completed with a `commit()` call";
}
String className = owner.substring(owner.lastIndexOf('/') + 1);
return String.format("This `%1$s` should be recycled after use with `#recycle()`",
className);
}
/**
* Ensures that the given allocate call in the given method has a
* corresponding recycle method, also within the same method, OR, the
* allocated resource flows out of the method (either as a return value, or
* into a field, or into some other method (with some known exceptions; e.g.
* passing a MotionEvent into another MotionEvent's constructor is fine)
*
* Returns true if an error was found
*/
private static boolean checkMethodFlow(ClassContext context, ClassNode classNode,
MethodNode method, MethodInsnNode call, String recycleOwner) {
CleanupTracker interpreter = new CleanupTracker(context, method, call, recycleOwner);
ResourceAnalyzer analyzer = new ResourceAnalyzer(interpreter);
interpreter.setAnalyzer(analyzer);
try {
analyzer.analyze(classNode.name, method);
if (!interpreter.isCleanedUp() && !interpreter.isEscaped()) {
Location location = context.getLocation(call);
String message = getErrorMessage(recycleOwner);
Issue issue = call.owner.equals(FRAGMENT_MANAGER_CLS)
? COMMIT_FRAGMENT : RECYCLE_RESOURCE;
context.report(issue, method, call, location, message);
return true;
}
} catch (AnalyzerException e) {
context.log(e, null);
}
return false;
}
@VisibleForTesting
static boolean hasReturnType(String owner, String desc) {
int descLen = desc.length();
int ownerLen = owner.length();
if (descLen < ownerLen + 3) {
return false;
}
if (desc.charAt(descLen - 1) != ';') {
return false;
}
int typeBegin = descLen - 2 - ownerLen;
if (desc.charAt(typeBegin - 1) != ')' || desc.charAt(typeBegin) != 'L') {
return false;
}
return desc.regionMatches(typeBegin + 1, owner, 0, ownerLen);
}
/**
* ASM interpreter which tracks the instances of the allocated resource, and
* checks whether it is eventually passed to a {@code recycle()} call. If the
* value flows out of the method (to a field, or a method call), it will
* also consider the resource recycled.
*/
private static class CleanupTracker extends Interpreter {
// Only identity matters, not value
private static final Value INSTANCE = BasicValue.INT_VALUE;
private static final Value RECYCLED = BasicValue.FLOAT_VALUE;
private static final Value UNKNOWN = BasicValue.UNINITIALIZED_VALUE;
private final ClassContext mContext;
private final MethodNode mMethod;
private final MethodInsnNode mObtainNode;
private boolean mIsCleanedUp;
private boolean mEscapes;
private final String mRecycleOwner;
private ResourceAnalyzer mAnalyzer;
public CleanupTracker(
@NonNull ClassContext context,
@NonNull MethodNode method,
@NonNull MethodInsnNode obtainNode,
@NonNull String recycleOwner) {
super(Opcodes.ASM4);
mContext = context;
mMethod = method;
mObtainNode = obtainNode;
mRecycleOwner = recycleOwner;
}
/**
* Sets the analyzer associated with the interpreter, such that it can
* get access to the execution frames
*/
void setAnalyzer(ResourceAnalyzer analyzer) {
mAnalyzer = analyzer;
}
/**
* Returns whether a recycle call was found for the given method
*
* @return true if the resource was recycled
*/
public boolean isCleanedUp() {
return mIsCleanedUp;
}
/**
* Returns whether the target resource escapes from the method, for
* example as a return value, or a field assignment, or getting passed
* to another method
*
* @return true if the resource escapes
*/
public boolean isEscaped() {
return mEscapes;
}
@Override
public Value newOperation(AbstractInsnNode node) throws AnalyzerException {
return UNKNOWN;
}
@Override
public Value newValue(final Type type) {
if (type != null && type.getSort() == Type.VOID) {
return null;
} else {
return UNKNOWN;
}
}
@Override
public Value copyOperation(AbstractInsnNode node, Value value) throws AnalyzerException {
return value;
}
@Override
public Value binaryOperation(AbstractInsnNode node, Value value1, Value value2)
throws AnalyzerException {
if (node.getOpcode() == Opcodes.PUTFIELD) {
if (value2 == INSTANCE) {
mEscapes = true;
}
}
return merge(value1, value2);
}
@Override
public Value naryOperation(AbstractInsnNode node, List values) throws AnalyzerException {
if (node == mObtainNode) {
return INSTANCE;
}
MethodInsnNode call = null;
if (node.getType() == AbstractInsnNode.METHOD_INSN) {
call = (MethodInsnNode) node;
if (node.getOpcode() == Opcodes.INVOKEVIRTUAL) {
if (call.name.equals(RECYCLE) && call.owner.equals(mRecycleOwner)) {
if (values != null && values.size() == 1 && values.get(0) == INSTANCE) {
mIsCleanedUp = true;
Frame frame = mAnalyzer.getCurrentFrame();
if (frame != null) {
int localSize = frame.getLocals();
for (int i = 0; i < localSize; i++) {
Value local = frame.getLocal(i);
if (local == INSTANCE) {
frame.setLocal(i, RECYCLED);
}
}
int stackSize = frame.getStackSize();
if (stackSize == 1 && frame.getStack(0) == INSTANCE) {
frame.pop();
frame.push(RECYCLED);
}
}
return RECYCLED;
}
} else if ((call.name.equals(COMMIT) || call.name.equals(COMMIT_ALLOWING_LOSS))
&& call.owner.equals(mRecycleOwner)) {
if (values != null && values.size() == 1 && values.get(0) == INSTANCE) {
mIsCleanedUp = true;
return INSTANCE;
}
} else if (call.name.equals(SHOW) && (
call.desc.equals(DIALOG_FRAGMENT_SHOW_DESC)
|| call.desc.equals(DIALOG_FRAGMENT_SHOW_V4_DESC))) {
if (values != null && values.size() == 3 && values.get(1) == INSTANCE) {
mIsCleanedUp = true;
return INSTANCE;
}
} else if (call.owner.equals(mRecycleOwner)
&& hasReturnType(mRecycleOwner, call.desc)) {
// Called method which returns self. This helps handle cases where you call
// createTransaction().method1().method2().method3().commit() -- if
// method1, 2 and 3 all return "this" then the commit call is really
// called on the createTransaction instance
return INSTANCE;
}
}
}
if (values != null && values.size() >= 1) {
// Skip the first element: method calls *on* the TypedArray are okay
int start = node.getOpcode() == Opcodes.INVOKESTATIC ? 0 : 1;
for (int i = 0, n = values.size(); i < n; i++) {
Object v = values.get(i);
if (v == INSTANCE && i >= start) {
// Known special cases
if (node.getOpcode() == Opcodes.INVOKESTATIC) {
assert call != null;
if (call.name.equals(OBTAIN) &&
call.owner.equals(MOTION_EVENT_CLS)) {
return UNKNOWN;
}
}
// Passing the instance to another method: could leak
// the instance out of this method (for example calling
// a method which recycles it on our behalf, or store it
// in some holder which will recycle it later). In this
// case, just assume that things are okay.
mEscapes = true;
} else if (v == RECYCLED && call != null) {
Location location = mContext.getLocation(call);
String message = String.format("This `%1$s` has already been recycled",
mRecycleOwner.substring(mRecycleOwner.lastIndexOf('/') + 1));
mContext.report(RECYCLE_RESOURCE, mMethod, call, location, message);
}
}
}
return UNKNOWN;
}
@Override
public Value unaryOperation(AbstractInsnNode node, Value value) throws AnalyzerException {
return value;
}
@Override
public Value ternaryOperation(AbstractInsnNode node, Value value1, Value value2,
Value value3) throws AnalyzerException {
if (value1 == RECYCLED || value2 == RECYCLED || value3 == RECYCLED) {
return RECYCLED;
} else if (value1 == INSTANCE || value2 == INSTANCE || value3 == INSTANCE) {
return INSTANCE;
}
return UNKNOWN;
}
@Override
public void returnOperation(AbstractInsnNode node, Value value1, Value value2)
throws AnalyzerException {
if (value1 == INSTANCE || value2 == INSTANCE) {
mEscapes = true;
}
}
@Override
public Value merge(Value value1, Value value2) {
if (value1 == RECYCLED || value2 == RECYCLED) {
return RECYCLED;
} else if (value1 == INSTANCE || value2 == INSTANCE) {
return INSTANCE;
}
return UNKNOWN;
}
}
private static class ResourceAnalyzer extends Analyzer {
private Frame mCurrent;
private Frame mFrame1;
private Frame mFrame2;
public ResourceAnalyzer(Interpreter interpreter) {
super(interpreter);
}
Frame getCurrentFrame() {
return mCurrent;
}
@Override
protected void init(String owner, MethodNode m) throws AnalyzerException {
mCurrent = mFrame2;
super.init(owner, m);
}
@Override
protected Frame newFrame(int nLocals, int nStack) {
// Stash the two most recent frame allocations. When init is called the second
// most recently seen frame is the current frame used during execution, which
// is where we need to replace INSTANCE with RECYCLED when the void
// recycle method is called.
Frame newFrame = super.newFrame(nLocals, nStack);
mFrame2 = mFrame1;
mFrame1 = newFrame;
return newFrame;
}
}
}