| /* |
| * 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.tools.lint.client.api.JavaParser.ResolvedMethod; |
| import static com.android.tools.lint.client.api.JavaParser.ResolvedNode; |
| import static com.android.tools.lint.client.api.JavaParser.TypeDescriptor; |
| |
| import com.android.annotations.NonNull; |
| import com.android.annotations.Nullable; |
| import com.android.tools.lint.client.api.JavaParser; |
| import com.android.tools.lint.detector.api.Category; |
| import com.android.tools.lint.detector.api.Context; |
| 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.Scope; |
| import com.android.tools.lint.detector.api.Severity; |
| |
| import java.io.File; |
| import java.util.Collections; |
| import java.util.List; |
| |
| import lombok.ast.Assert; |
| import lombok.ast.AstVisitor; |
| import lombok.ast.Block; |
| import lombok.ast.Case; |
| import lombok.ast.ClassDeclaration; |
| import lombok.ast.ConstructorDeclaration; |
| import lombok.ast.DoWhile; |
| import lombok.ast.Expression; |
| import lombok.ast.ExpressionStatement; |
| import lombok.ast.For; |
| import lombok.ast.ForwardingAstVisitor; |
| import lombok.ast.If; |
| import lombok.ast.MethodDeclaration; |
| import lombok.ast.MethodInvocation; |
| import lombok.ast.Node; |
| import lombok.ast.NormalTypeBody; |
| import lombok.ast.Return; |
| import lombok.ast.Statement; |
| import lombok.ast.VariableDeclaration; |
| import lombok.ast.VariableDefinition; |
| import lombok.ast.VariableReference; |
| import lombok.ast.While; |
| |
| /** |
| * Detector looking for SharedPreferences.edit() calls without a corresponding |
| * commit() or apply() call |
| */ |
| public class SharedPrefsDetector extends Detector implements Detector.JavaScanner { |
| /** The main issue discovered by this detector */ |
| public static final Issue ISSUE = Issue.create( |
| "CommitPrefEdits", //$NON-NLS-1$ |
| "Missing `commit()` on `SharedPreference` editor", |
| |
| "After calling `edit()` on a `SharedPreference`, you must call `commit()` " + |
| "or `apply()` on the editor to save the results.", |
| |
| Category.CORRECTNESS, |
| 6, |
| Severity.WARNING, |
| new Implementation( |
| SharedPrefsDetector.class, |
| Scope.JAVA_FILE_SCOPE)); |
| |
| public static final String ANDROID_CONTENT_SHARED_PREFERENCES = |
| "android.content.SharedPreferences"; //$NON-NLS-1$ |
| private static final String ANDROID_CONTENT_SHARED_PREFERENCES_EDITOR = |
| "android.content.SharedPreferences.Editor"; //$NON-NLS-1$ |
| |
| /** Constructs a new {@link SharedPrefsDetector} check */ |
| public SharedPrefsDetector() { |
| } |
| |
| @Override |
| public boolean appliesTo(@NonNull Context context, @NonNull File file) { |
| return true; |
| } |
| |
| |
| // ---- Implements JavaScanner ---- |
| |
| @Override |
| public List<String> getApplicableMethodNames() { |
| return Collections.singletonList("edit"); //$NON-NLS-1$ |
| } |
| |
| @Nullable |
| private static NormalTypeBody findSurroundingTypeBody(Node scope) { |
| while (scope != null) { |
| Class<? extends 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 == NormalTypeBody.class) { |
| return (NormalTypeBody) scope; |
| } |
| |
| scope = scope.getParent(); |
| } |
| |
| return null; |
| } |
| |
| |
| @Override |
| public void visitMethod(@NonNull JavaContext context, @Nullable AstVisitor visitor, |
| @NonNull MethodInvocation node) { |
| assert node.astName().astValue().equals("edit"); |
| |
| boolean verifiedType = false; |
| ResolvedNode resolve = context.resolve(node); |
| if (resolve instanceof ResolvedMethod) { |
| ResolvedMethod method = (ResolvedMethod) resolve; |
| TypeDescriptor returnType = method.getReturnType(); |
| if (returnType == null || |
| !returnType.matchesName(ANDROID_CONTENT_SHARED_PREFERENCES_EDITOR)) { |
| return; |
| } |
| verifiedType = true; |
| } |
| |
| Expression operand = node.astOperand(); |
| if (operand == null) { |
| return; |
| } |
| |
| // Looking for the specific pattern where you assign the edit() result |
| // to a local variable; this means we won't recognize some other usages |
| // of the API (e.g. assigning it to a previously declared variable) but |
| // is needed until we have type attribution in the AST itself. |
| Node parent = node.getParent(); |
| |
| VariableDefinition definition = getLhs(parent); |
| boolean allowCommitBeforeTarget; |
| if (definition == null) { |
| if (operand instanceof VariableReference) { |
| if (!verifiedType) { |
| NormalTypeBody body = findSurroundingTypeBody(parent); |
| if (body == null) { |
| return; |
| } |
| String variableName = ((VariableReference) operand).astIdentifier().astValue(); |
| String type = getFieldType(body, variableName); |
| if (type == null || !type.equals("SharedPreferences")) { //$NON-NLS-1$ |
| return; |
| } |
| } |
| allowCommitBeforeTarget = true; |
| } else { |
| return; |
| } |
| } else { |
| if (!verifiedType) { |
| String type = definition.astTypeReference().toString(); |
| if (!type.endsWith("SharedPreferences.Editor")) { //$NON-NLS-1$ |
| if (!type.equals("Editor") || //$NON-NLS-1$ |
| !LintUtils.isImported(context.getCompilationUnit(), |
| ANDROID_CONTENT_SHARED_PREFERENCES_EDITOR)) { |
| return; |
| } |
| } |
| } |
| allowCommitBeforeTarget = false; |
| } |
| |
| Node method = JavaContext.findSurroundingMethod(parent); |
| if (method == null) { |
| return; |
| } |
| |
| CommitFinder finder = new CommitFinder(context, node, allowCommitBeforeTarget); |
| method.accept(finder); |
| if (!finder.isCommitCalled()) { |
| context.report(ISSUE, method, context.getLocation(node), |
| "`SharedPreferences.edit()` without a corresponding `commit()` or `apply()` call"); |
| } |
| } |
| |
| @Nullable |
| private static String getFieldType(@NonNull NormalTypeBody cls, @NonNull String name) { |
| List<Node> children = cls.getChildren(); |
| for (Node child : children) { |
| if (child.getClass() == VariableDeclaration.class) { |
| VariableDeclaration declaration = (VariableDeclaration) child; |
| VariableDefinition definition = declaration.astDefinition(); |
| return definition.astTypeReference().toString(); |
| } |
| } |
| |
| return null; |
| } |
| |
| @Nullable |
| private static VariableDefinition getLhs(@NonNull Node node) { |
| while (node != null) { |
| Class<? extends Node> type = node.getClass(); |
| // The Lombok AST uses a flat hierarchy of node type implementation classes |
| // so no need to do instanceof stuff here. |
| if (type == MethodDeclaration.class || type == ConstructorDeclaration.class) { |
| return null; |
| } |
| if (type == VariableDefinition.class) { |
| return (VariableDefinition) node; |
| } |
| |
| node = node.getParent(); |
| } |
| |
| return null; |
| } |
| |
| private static class CommitFinder extends ForwardingAstVisitor { |
| /** The target edit call */ |
| private final MethodInvocation mTarget; |
| /** whether it allows the commit call to be seen before the target node */ |
| private final boolean mAllowCommitBeforeTarget; |
| |
| private final JavaContext mContext; |
| |
| /** Whether we've found one of the commit/cancel methods */ |
| private boolean mFound; |
| /** Whether we've seen the target edit node yet */ |
| private boolean mSeenTarget; |
| |
| private CommitFinder(JavaContext context, MethodInvocation target, |
| boolean allowCommitBeforeTarget) { |
| mContext = context; |
| mTarget = target; |
| mAllowCommitBeforeTarget = allowCommitBeforeTarget; |
| } |
| |
| @Override |
| public boolean visitMethodInvocation(MethodInvocation node) { |
| if (node == mTarget) { |
| mSeenTarget = true; |
| } else if (mAllowCommitBeforeTarget || mSeenTarget || node.astOperand() == mTarget) { |
| String name = node.astName().astValue(); |
| boolean isCommit = "commit".equals(name); |
| if (isCommit || "apply".equals(name)) { //$NON-NLS-1$ //$NON-NLS-2$ |
| // TODO: Do more flow analysis to see whether we're really calling commit/apply |
| // on the right type of object? |
| mFound = true; |
| |
| ResolvedNode resolved = mContext.resolve(node); |
| if (resolved instanceof JavaParser.ResolvedMethod) { |
| ResolvedMethod method = (ResolvedMethod) resolved; |
| JavaParser.ResolvedClass clz = method.getContainingClass(); |
| if (clz.isSubclassOf("android.content.SharedPreferences.Editor", false) |
| && mContext.getProject().getMinSdkVersion().getApiLevel() >= 9) { |
| // See if the return value is read: can only replace commit with |
| // apply if the return value is not considered |
| Node parent = node.getParent(); |
| boolean returnValueIgnored = false; |
| if (parent instanceof MethodDeclaration || |
| parent instanceof ConstructorDeclaration || |
| parent instanceof ClassDeclaration || |
| parent instanceof Block || |
| parent instanceof ExpressionStatement) { |
| returnValueIgnored = true; |
| } else if (parent instanceof Statement) { |
| if (parent instanceof If) { |
| returnValueIgnored = ((If) parent).astCondition() != node; |
| } else if (parent instanceof Return) { |
| returnValueIgnored = false; |
| } else if (parent instanceof VariableDeclaration) { |
| returnValueIgnored = false; |
| } else if (parent instanceof For) { |
| returnValueIgnored = ((For) parent).astCondition() != node; |
| } else if (parent instanceof While) { |
| returnValueIgnored = ((While) parent).astCondition() != node; |
| } else if (parent instanceof DoWhile) { |
| returnValueIgnored = ((DoWhile) parent).astCondition() != node; |
| } else if (parent instanceof Case) { |
| returnValueIgnored = ((Case) parent).astCondition() != node; |
| } else if (parent instanceof Assert) { |
| returnValueIgnored = ((Assert) parent).astAssertion() != node; |
| } else { |
| returnValueIgnored = true; |
| } |
| } |
| if (returnValueIgnored && isCommit) { |
| String message = "Consider using `apply()` instead; `commit` writes " |
| + "its data to persistent storage immediately, whereas " |
| + "`apply` will handle it in the background"; |
| mContext.report(ISSUE, node, mContext.getLocation(node), message); |
| } |
| } |
| } |
| } |
| } |
| |
| return super.visitMethodInvocation(node); |
| } |
| |
| @Override |
| public boolean visitReturn(Return node) { |
| if (node.astValue() == mTarget) { |
| // If you just do "return editor.commit() don't warn |
| mFound = true; |
| } |
| return super.visitReturn(node); |
| } |
| |
| boolean isCommitCalled() { |
| return mFound; |
| } |
| } |
| } |