1 /*
<lambda>null2  * Copyright 2023 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.compose.ui.lint
18 
19 import androidx.compose.lint.Names
20 import androidx.compose.lint.Package
21 import androidx.compose.lint.isInPackageName
22 import com.android.tools.lint.detector.api.Category
23 import com.android.tools.lint.detector.api.Detector
24 import com.android.tools.lint.detector.api.Implementation
25 import com.android.tools.lint.detector.api.Issue
26 import com.android.tools.lint.detector.api.JavaContext
27 import com.android.tools.lint.detector.api.Scope
28 import com.android.tools.lint.detector.api.Severity
29 import com.android.tools.lint.detector.api.SourceCodeScanner
30 import com.intellij.psi.PsiClass
31 import com.intellij.psi.PsiMethod
32 import java.util.EnumSet
33 import org.jetbrains.uast.UBlockExpression
34 import org.jetbrains.uast.UCallExpression
35 import org.jetbrains.uast.UElement
36 import org.jetbrains.uast.ULambdaExpression
37 import org.jetbrains.uast.UMethod
38 
39 @Suppress("UnstableApiUsage")
40 class SuspiciousCompositionLocalModifierReadDetector : Detector(), SourceCodeScanner {
41 
42     private val NodeLifecycleCallbacks = listOf("onAttach", "onDetach")
43 
44     override fun getApplicableMethodNames(): List<String> =
45         listOf(Names.Ui.Node.CurrentValueOf.shortName)
46 
47     override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
48         if (!method.isInPackageName(Names.Ui.Node.PackageName)) return
49         reportIfInNodeLifecycleCallback(context, node, node)
50         reportIfInLazyBlock(context, node, node)
51     }
52 
53     private tailrec fun reportIfInNodeLifecycleCallback(
54         context: JavaContext,
55         node: UElement?,
56         usage: UCallExpression
57     ) {
58         if (node == null) {
59             return
60         } else if (node is UMethod) {
61             if (node.containingClass.isClConsumerNode()) {
62                 if (node.name in NodeLifecycleCallbacks) {
63                     report(context, usage) { localBeingRead ->
64                         val action =
65                             node.name.removePrefix("on").replaceFirstChar { it.lowercase() }
66 
67                         "Reading $localBeingRead in ${node.name} will only access the " +
68                             "CompositionLocal's value when the modifier is ${action}ed. " +
69                             "To be notified of the latest value of the CompositionLocal, read " +
70                             "the value in one of the modifier's other callbacks."
71                     }
72                 } else if (node.isConstructor) {
73                     report(context, usage) {
74                         "CompositionLocals cannot be read in modifiers before the node is attached."
75                     }
76                 }
77             }
78             return
79         } else if (node is UBlockExpression && node.uastParent is ULambdaExpression) {
80             return
81         }
82 
83         reportIfInNodeLifecycleCallback(context, node.uastParent, usage)
84     }
85 
86     private tailrec fun reportIfInLazyBlock(
87         context: JavaContext,
88         node: UElement?,
89         usage: UCallExpression
90     ) {
91         if (node == null) {
92             return
93         } else if (node is UCallExpression && node.isLazyDelegate()) {
94             report(context, usage) { localBeingRead ->
95                 "Reading $localBeingRead lazily will only access the CompositionLocal's value " +
96                     "once. To be notified of the latest value of the CompositionLocal, read " +
97                     "the value in one of the modifier's callbacks."
98             }
99             return
100         }
101 
102         reportIfInLazyBlock(context, node.uastParent, usage)
103     }
104 
105     private inline fun report(
106         context: JavaContext,
107         usage: UCallExpression,
108         message: (compositionLocalName: String) -> String
109     ) {
110         val localBeingRead =
111             usage.getArgumentForParameter(1)?.sourcePsi?.text ?: "a composition local"
112 
113         context.report(
114             SuspiciousCompositionLocalModifierRead,
115             context.getLocation(usage),
116             message(localBeingRead)
117         )
118     }
119 
120     private fun PsiClass?.isClConsumerNode(): Boolean =
121         this?.implementsListTypes?.any { it.canonicalText == ClConsumerModifierNode } == true
122 
123     private fun UCallExpression.isLazyDelegate(): Boolean =
124         resolve()?.run { isInPackageName(Package("kotlin")) && name == "lazy" } == true
125 
126     companion object {
127         private const val ClConsumerModifierNode =
128             "androidx.compose.ui.node.CompositionLocalConsumerModifierNode"
129 
130         val SuspiciousCompositionLocalModifierRead =
131             Issue.create(
132                 "SuspiciousCompositionLocalModifierRead",
133                 "CompositionLocals should not be read in Modifier.onAttach() or Modifier.onDetach()",
134                 "Jetpack Compose is unable to send updated values of a CompositionLocal when it's " +
135                     "read in a Modifier.Node's initializer and onAttach() or onDetach() callbacks. " +
136                     "Modifier.Node's callbacks are not aware of snapshot reads, and their lifecycle " +
137                     "callbacks are not invoked on every recomposition. If you read a " +
138                     "CompositionLocal in onAttach() or onDetach(), you will only get the " +
139                     "CompositionLocal's value once at the moment of the read, which may lead to " +
140                     "unexpected behaviors. We recommend instead reading CompositionLocals at " +
141                     "time-of-use in callbacks that apply your Modifier's behavior, like measure() " +
142                     "for LayoutModifierNode, draw() for DrawModifierNode, and so on. To observe the " +
143                     "value of the CompositionLocal manually, extend from the ObserverNode interface " +
144                     "and place the read inside an observeReads {} block within the " +
145                     "onObservedReadsChanged() callback.",
146                 Category.CORRECTNESS,
147                 3,
148                 Severity.ERROR,
149                 Implementation(
150                     SuspiciousCompositionLocalModifierReadDetector::class.java,
151                     EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES)
152                 )
153             )
154     }
155 }
156