1 /*
<lambda>null2 * Copyright 2019 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.lifecycle.lint
18
19 import androidx.lifecycle.lint.LifecycleWhenChecks.Companion.ISSUE
20 import androidx.lifecycle.lint.LifecycleWhenVisitor.SearchState.DONT_SEARCH
21 import androidx.lifecycle.lint.LifecycleWhenVisitor.SearchState.FOUND
22 import androidx.lifecycle.lint.LifecycleWhenVisitor.SearchState.SEARCH
23 import com.android.SdkConstants
24 import com.android.tools.lint.detector.api.Category
25 import com.android.tools.lint.detector.api.Detector
26 import com.android.tools.lint.detector.api.Implementation
27 import com.android.tools.lint.detector.api.Issue
28 import com.android.tools.lint.detector.api.JavaContext
29 import com.android.tools.lint.detector.api.Scope
30 import com.android.tools.lint.detector.api.Severity
31 import com.android.tools.lint.detector.api.SourceCodeScanner
32 import com.intellij.psi.PsiClassType
33 import com.intellij.psi.PsiMethod
34 import com.intellij.psi.PsiWildcardType
35 import com.intellij.psi.util.PsiTypesUtil
36 import java.util.ArrayDeque
37 import org.jetbrains.kotlin.asJava.elements.KtLightModifierList
38 import org.jetbrains.kotlin.lexer.KtTokens
39 import org.jetbrains.uast.UCallExpression
40 import org.jetbrains.uast.UClass
41 import org.jetbrains.uast.UDeclaration
42 import org.jetbrains.uast.UElement
43 import org.jetbrains.uast.UIfExpression
44 import org.jetbrains.uast.ULambdaExpression
45 import org.jetbrains.uast.UMethod
46 import org.jetbrains.uast.USwitchClauseExpression
47 import org.jetbrains.uast.USwitchClauseExpressionWithBody
48 import org.jetbrains.uast.UTryExpression
49 import org.jetbrains.uast.toUElement
50 import org.jetbrains.uast.tryResolve
51 import org.jetbrains.uast.visitor.AbstractUastVisitor
52 import org.jetbrains.uast.visitor.UastVisitor
53
54 // both old and new ones
55 private val CONTINUATION_NAMES =
56 setOf(
57 "kotlin.coroutines.Continuation<? super kotlin.Unit>",
58 "kotlin.coroutines.experimental.Continuation<? super kotlin.Unit>"
59 )
60
61 internal fun errorMessage(whenMethodName: String) =
62 "Unsafe View access from finally/catch block inside of `Lifecycle.$whenMethodName` scope"
63
64 internal const val SECONDARY_ERROR_MESSAGE = "Internal View access"
65
66 private val LIFECYCLE_WHEN_APPLICABLE_METHOD_NAMES =
67 listOf("whenCreated", "whenStarted", "whenResumed")
68
69 class LifecycleWhenChecks : Detector(), SourceCodeScanner {
70
71 override fun getApplicableMethodNames() = LIFECYCLE_WHEN_APPLICABLE_METHOD_NAMES
72
73 override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
74 val valueArguments = node.valueArguments
75 if (valueArguments.size != 1 || !method.isLifecycleWhenExtension(context)) {
76 return
77 }
78 (valueArguments[0] as? ULambdaExpression)
79 ?.body
80 ?.accept(LifecycleWhenVisitor(context, method.name))
81 }
82
83 companion object {
84 val ISSUE =
85 Issue.create(
86 id = "UnsafeLifecycleWhenUsage",
87 briefDescription =
88 "Unsafe UI operation in finally/catch of " +
89 "Lifecycle.whenStarted of similar method",
90 explanation =
91 """If the `Lifecycle` is destroyed within the block of \
92 `Lifecycle.whenStarted` or any similar `Lifecycle.when` method is suspended, \
93 the block will be cancelled, which will also cancel any child coroutine \
94 launched inside the block. As as a result, If you have a try finally block \
95 in your code, the finally might run after the Lifecycle moves outside \
96 the desired state. It is recommended to check the `Lifecycle.isAtLeast` \
97 before accessing UI in finally block. Similarly, \
98 if you have a catch statement that might catch `CancellationException`, \
99 you should check the `Lifecycle.isAtLeast` before accessing the UI. See \
100 documentation of `Lifecycle.whenStateAtLeast` for more details""",
101 category = Category.CORRECTNESS,
102 severity = Severity.ERROR,
103 implementation =
104 Implementation(LifecycleWhenChecks::class.java, Scope.JAVA_FILE_SCOPE),
105 androidSpecific = true
106 )
107 }
108 }
109
110 internal class LifecycleWhenVisitor(
111 private val context: JavaContext,
112 private val whenMethodName: String
113 ) : AbstractUastVisitor() {
114 enum class SearchState {
115 DONT_SEARCH,
116 SEARCH,
117 FOUND
118 }
119
120 data class State(val checkUIAccess: Boolean, val suspendCallSearch: SearchState)
121
Statenull122 fun State.foundSuspendCall() = suspendCallSearch == FOUND
123
124 private val states = ArrayDeque<State>()
125
126 init {
127 states.push(State(checkUIAccess = false, suspendCallSearch = DONT_SEARCH))
128 }
129
130 private val currentState: State
131 get() = states.first
132
133 private val recursiveHelper = RecursiveVisitHelper()
134
withNewStatenull135 fun withNewState(state: State, block: () -> Unit): State {
136 states.push(state)
137 block()
138 val lastState = states.pop()
139 // inner scope found suspend call and current state is looking for it => propagate it up
140 if (currentState.suspendCallSearch == SEARCH && lastState.foundSuspendCall()) {
141 updateSuspendCallSearch(FOUND)
142 }
143 return lastState
144 }
145
withNewStatenull146 fun withNewState(suspendCallSearch: SearchState, block: () -> Unit): State {
147 return withNewState(State(currentState.checkUIAccess, suspendCallSearch), block)
148 }
149
withNewStatenull150 fun withNewState(checkUIAccess: Boolean, block: () -> Unit): State {
151 return withNewState(State(checkUIAccess, currentState.suspendCallSearch), block)
152 }
153
visitTryExpressionnull154 override fun visitTryExpression(node: UTryExpression): Boolean {
155 val stateAfterTry = withNewState(SEARCH) { node.tryClause.accept(this) }
156 val checkView = currentState.checkUIAccess || stateAfterTry.foundSuspendCall()
157 // TODO: support catch
158 withNewState(checkView) { node.finallyClause?.accept(this) }
159 return true
160 }
161
updateSuspendCallSearchnull162 fun updateSuspendCallSearch(newState: SearchState) {
163 val previous = states.pop()
164 states.push(State(previous.checkUIAccess, newState))
165 }
166
visitCallExpressionnull167 override fun visitCallExpression(node: UCallExpression): Boolean {
168 val psiMethod = node.resolve() ?: return super.visitCallExpression(node)
169
170 if (psiMethod.isSuspend()) {
171 updateSuspendCallSearch(FOUND)
172 // go inside and check it doesn't access
173 recursiveHelper.visitIfNeeded(psiMethod, this)
174 }
175
176 if (currentState.checkUIAccess) {
177 checkUiAccess(context, node, whenMethodName)
178 }
179 return super.visitCallExpression(node)
180 }
181
visitLambdaExpressionnull182 override fun visitLambdaExpression(node: ULambdaExpression): Boolean {
183 // we probably should actually look at contracts,
184 // because only `callsInPlace` lambdas inherit coroutine scope. But contracts aren't stable
185 // yet =(
186 // if lambda is suspending it means something else defined its scope
187 return node.isSuspendLambda() || super.visitLambdaExpression(node)
188 }
189
190 // ignore classes defined inline
visitClassnull191 override fun visitClass(node: UClass) = true
192
193 // ignore fun defined inline
194 override fun visitDeclaration(node: UDeclaration) = true
195
196 override fun visitIfExpression(node: UIfExpression): Boolean {
197 if (!currentState.checkUIAccess) return false
198 val method = node.condition.tryResolve() as? PsiMethod ?: return false
199 if (method.isLifecycleIsAtLeastMethod(context)) {
200 withNewState(checkUIAccess = false) { node.thenExpression?.accept(this) }
201 node.elseExpression?.accept(this)
202 return true
203 }
204 return false
205 }
206
visitSwitchClauseExpressionnull207 override fun visitSwitchClauseExpression(node: USwitchClauseExpression): Boolean {
208 // check each case in the switch statement
209 node.caseValues.forEach { expression ->
210 val method = expression.tryResolve() as? PsiMethod ?: return false
211 if (method.isLifecycleIsAtLeastMethod(context)) {
212 // If the case containing the lifecycle check evaluates to true, check the body
213 withNewState(checkUIAccess = false) {
214 (node as? USwitchClauseExpressionWithBody)?.body?.expressions?.forEach {
215 it.accept(this)
216 }
217 }
218 return true
219 }
220 }
221 return false
222 }
223 }
224
225 private const val DISPATCHER_CLASS_NAME = "androidx.lifecycle.PausingDispatcherKt"
226 private const val LIFECYCLE_CLASS_NAME = "androidx.lifecycle.Lifecycle"
227
PsiMethodnull228 private fun PsiMethod.isLifecycleWhenExtension(context: JavaContext): Boolean {
229 return name in LIFECYCLE_WHEN_APPLICABLE_METHOD_NAMES &&
230 context.evaluator.isMemberInClass(this, DISPATCHER_CLASS_NAME) &&
231 context.evaluator.isStatic(this)
232 }
233
PsiMethodnull234 private fun PsiMethod.isLifecycleIsAtLeastMethod(context: JavaContext): Boolean {
235 return name == "isAtLeast" && context.evaluator.isMemberInClass(this, LIFECYCLE_CLASS_NAME)
236 }
237
238 // TODO: find a better way!
ULambdaExpressionnull239 private fun ULambdaExpression.isSuspendLambda(): Boolean {
240 val expressionClass = getExpressionType() as? PsiClassType ?: return false
241 val params = expressionClass.parameters
242 // suspend functions are FunctionN<*, Continuation, Obj>
243 if (params.size < 2) {
244 return false
245 }
246 val superBound = (params[params.size - 2] as? PsiWildcardType)?.superBound as? PsiClassType
247 return if (superBound != null) {
248 superBound.canonicalText in CONTINUATION_NAMES
249 } else {
250 false
251 }
252 }
253
PsiMethodnull254 private fun PsiMethod.isSuspend(): Boolean {
255 val modifiers = modifierList as? KtLightModifierList<*>
256 return modifiers?.kotlinOrigin?.hasModifier(KtTokens.SUSPEND_KEYWORD) ?: false
257 }
258
checkUiAccessnull259 fun checkUiAccess(context: JavaContext, node: UCallExpression, whenMethodName: String) {
260 val checkVisitor = CheckAccessUiVisitor(context)
261 node.accept(checkVisitor)
262 checkVisitor.uiAccessNode?.let { accessNode ->
263 val mainLocation = context.getLocation(node)
264 if (accessNode != node) {
265 mainLocation.withSecondary(context.getLocation(accessNode), SECONDARY_ERROR_MESSAGE)
266 }
267 context.report(ISSUE, mainLocation, errorMessage(whenMethodName))
268 }
269 }
270
271 internal class CheckAccessUiVisitor(private val context: JavaContext) : AbstractUastVisitor() {
272 var uiAccessNode: UCallExpression? = null
273 private val recursiveHelper = RecursiveVisitHelper()
274
visitElementnull275 override fun visitElement(node: UElement) = uiAccessNode != null
276
277 override fun visitCallExpression(node: UCallExpression): Boolean {
278 val receiverClass = PsiTypesUtil.getPsiClass(node.receiverType)
279 if (context.evaluator.extendsClass(receiverClass, SdkConstants.CLASS_VIEW, false)) {
280 uiAccessNode = node
281 return true
282 }
283 recursiveHelper.visitIfNeeded(node.resolve(), this)
284 return super.visitCallExpression(node)
285 }
286
287 // ignore classes defined inline
visitClassnull288 override fun visitClass(node: UClass) = true
289
290 // ignore fun defined inline
291 override fun visitDeclaration(node: UDeclaration) = true
292
293 // issue here, that we ignore calls like .let { } that calls lambda inplace
294 override fun visitLambdaExpression(node: ULambdaExpression) = true
295 }
296
297 class RecursiveVisitHelper {
298 private val maxInspectionDepth = 3
299 private val visitedMethods = mutableSetOf<UMethod>()
300 private var depth = 0
301
302 fun visitIfNeeded(psiMethod: PsiMethod?, visitor: UastVisitor) {
303 val method = psiMethod?.toUElement() as? UMethod
304 if (method != null && method !in visitedMethods) {
305 visitedMethods.add(method)
306 if (depth < maxInspectionDepth) {
307 depth++
308 method.uastBody?.accept(visitor)
309 depth--
310 }
311 }
312 }
313 }
314