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