1 /*
<lambda>null2  * Copyright 2024 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.Ui.Platform.LocalConfiguration
20 import androidx.compose.lint.Names.Ui.Platform.LocalResources
21 import androidx.compose.lint.Package
22 import androidx.compose.lint.isInPackageName
23 import com.android.tools.lint.client.api.UElementHandler
24 import com.android.tools.lint.detector.api.Category
25 import com.android.tools.lint.detector.api.Context
26 import com.android.tools.lint.detector.api.Detector
27 import com.android.tools.lint.detector.api.Implementation
28 import com.android.tools.lint.detector.api.Issue
29 import com.android.tools.lint.detector.api.JavaContext
30 import com.android.tools.lint.detector.api.LintFix
31 import com.android.tools.lint.detector.api.Scope
32 import com.android.tools.lint.detector.api.Severity
33 import com.android.tools.lint.detector.api.SourceCodeScanner
34 import com.android.tools.lint.detector.api.UastLintUtils.Companion.tryResolveUDeclaration
35 import com.intellij.psi.PsiMethod
36 import java.util.EnumSet
37 import org.jetbrains.uast.UElement
38 import org.jetbrains.uast.UExpression
39 import org.jetbrains.uast.UQualifiedReferenceExpression
40 import org.jetbrains.uast.USimpleNameReferenceExpression
41 import org.jetbrains.uast.UVariable
42 import org.jetbrains.uast.isUastChildOf
43 import org.jetbrains.uast.matchesQualified
44 import org.jetbrains.uast.skipParenthesizedExprDown
45 import org.jetbrains.uast.tryResolve
46 
47 /**
48  * Detector that warns for calls to LocalContext.current.resources and
49  * LocalContext.current.resources.configuration - changes to the configuration object will not cause
50  * these to recompose, so callers of these APIs will not be notified when it changes. For resources
51  * this is important because APIs such as Resources.getString() can return new values when the
52  * configuration changes. LocalResources.current and LocalConfiguration.current should be used
53  * instead.
54  */
55 class LocalContextResourcesConfigurationReadDetector : Detector(), SourceCodeScanner {
56     override fun getApplicableUastTypes() = listOf(UQualifiedReferenceExpression::class.java)
57 
58     /**
59      * List of `LocalContext.current.resources` calls (this includes 'sub calls' inside a larger
60      * `LocalContext.current.resources.configuration` call
61      */
62     private val fullyQualifiedResourceCalls = mutableListOf<UQualifiedReferenceExpression>()
63 
64     /** List of `LocalContext.current.resources.configuration` calls */
65     private val fullyQualifiedConfigurationCalls = mutableListOf<UQualifiedReferenceExpression>()
66 
67     /** List of calls to Context#resources */
68     private val contextResourcesCalls = mutableListOf<UExpression>()
69 
70     /**
71      * List of `context` references that are used to call resources.configuration
72      *
73      * E.g. for:
74      *
75      * val context = LocalContext.current val resources = context.resources val configuration =
76      * resources.configuration
77      *
78      * We will add the `context` in `context.resources` to this list
79      */
80     private val contextsReferencedFromResourcesConfigurationCall = mutableListOf<UExpression>()
81 
82     override fun afterCheckFile(context: Context) {
83         // Will always be JavaContext when we are checking a Kotlin source file
84         if (context is JavaContext) {
85             fullyQualifiedResourceCalls.forEach { resourcesCall ->
86                 // Make sure that this resources call is not inside a configuration call, and that
87                 // this resources call does not contain the context call that is used by a later
88                 // resources call
89                 val isInsideConfigurationCall =
90                     fullyQualifiedConfigurationCalls.any { configurationCall ->
91                         resourcesCall.isUastChildOf(configurationCall)
92                     }
93                 val containsContextCallCalledByResourcesCall =
94                     contextsReferencedFromResourcesConfigurationCall.any { call ->
95                         call.isUastChildOf(resourcesCall)
96                     }
97                 if (!isInsideConfigurationCall && !containsContextCallCalledByResourcesCall) {
98                     context.report(
99                         LocalContextResourcesRead,
100                         resourcesCall,
101                         context.getNameLocation(resourcesCall),
102                         "Reading Resources using $LocalContextCurrentResources",
103                         LintFix.create()
104                             .replace()
105                             .name("Replace with $LocalResourcesCurrent")
106                             .all()
107                             .with(LocalResourcesCurrent)
108                             .imports(LocalResources.javaFqn)
109                             .autoFix()
110                             .build()
111                     )
112                 }
113             }
114             contextResourcesCalls.forEach { resourcesCall ->
115                 // Only report if we didn't report a resources.configuration error that came
116                 // from the same context within this context.resources call
117                 if (
118                     contextsReferencedFromResourcesConfigurationCall.none { call ->
119                         call.isUastChildOf(resourcesCall)
120                     }
121                 ) {
122                     context.report(
123                         LocalContextResourcesRead,
124                         resourcesCall,
125                         context.getNameLocation(resourcesCall),
126                         "Reading Resources using $LocalContextCurrentResources"
127                     )
128                 }
129             }
130         }
131         fullyQualifiedResourceCalls.clear()
132         fullyQualifiedConfigurationCalls.clear()
133         contextResourcesCalls.clear()
134         contextsReferencedFromResourcesConfigurationCall.clear()
135     }
136 
137     override fun createUastHandler(context: JavaContext): UElementHandler =
138         object : UElementHandler() {
139             override fun visitQualifiedReferenceExpression(node: UQualifiedReferenceExpression) {
140                 // Fast path for whole configuration string - note the later logic would catch
141                 // this (and variants that use method calls such as getResources() instead), but
142                 // we want a fast path so we can suggest a replacement.
143                 if (node.matchesQualified(LocalContextCurrentResourcesConfiguration)) {
144                     fullyQualifiedConfigurationCalls += node
145                     context.report(
146                         LocalContextConfigurationRead,
147                         node,
148                         context.getNameLocation(node),
149                         "Reading Configuration using $LocalContextCurrentResourcesConfiguration",
150                         LintFix.create()
151                             .replace()
152                             .name("Replace with $LocalConfigurationCurrent")
153                             .all()
154                             .with(LocalConfigurationCurrent)
155                             .imports(LocalConfiguration.javaFqn)
156                             .autoFix()
157                             .build()
158                     )
159                     return
160                 }
161 
162                 // Check for the whole resources string. We need to delay reporting until after we
163                 // analyze the file, as we could find the resources string inside the configuration
164                 // string as well, and in that case we only want to report the configuration error.
165                 // So after we check the file, we only report the resource string if it was not
166                 // inside a configuration string.
167                 if (node.matchesQualified(LocalContextCurrentResources)) {
168                     fullyQualifiedResourceCalls += node
169                     return
170                 }
171 
172                 // Simple logic to try and match a few specific cases (there are many cases that
173                 // this won't warn for) where the chain is split up
174                 // E.g. val context = LocalContext.current, val resources = context.resources,
175                 // val configuration = resources.configuration
176                 // A future improvement would be to catch receiver scope cases, such as
177                 // `with(LocalContext.current.resources) { configuration... }`, but this is more
178                 // complicated and error prone
179 
180                 // See if this is a resources.configuration call or a context.resources call
181                 val selector = node.selector.skipParenthesizedExprDown()
182                 val configurationCall = selector.isCallToGetConfiguration()
183                 val resourcesCall = selector.isCallToGetResources()
184                 if (!configurationCall && !resourcesCall) return
185 
186                 // Either the expression with resources when the selector is resources.configuration
187                 // or the expression with context when the selector is context.resources
188                 val parent = node.receiver.skipParenthesizedExprDown()
189 
190                 val contextExpression =
191                     if (resourcesCall) {
192                         parent
193                     } else {
194                         findContextExpressionFromResourcesConfigurationExpression(parent) ?: return
195                     }
196 
197                 // Try and find out where this context came from
198                 val contextSource =
199                     when (contextExpression) {
200                         // Still part of a qualified expression, e.g. LocalContext.current
201                         is UQualifiedReferenceExpression -> contextExpression
202                         // Possible reference to a variable, e.g. val context =
203                         // LocalContext.current,
204                         // and this USimpleNameReferenceExpression is `context`
205                         is USimpleNameReferenceExpression -> {
206                             // If it is a property such as val context = LocalContext.current, find
207                             // the initializer
208                             val initializer =
209                                 (contextExpression.tryResolveUDeclaration() as? UVariable)
210                                     ?.uastInitializer ?: return
211                             if (initializer !is UQualifiedReferenceExpression) return
212                             initializer
213                         }
214                         else -> return
215                     }
216 
217                 if (contextSource.matchesQualified("LocalContext.current")) {
218                     // We can be here from two cases, either we were analyzing a call to
219                     // context.resources, or a call to resources.configuration. Since calls to
220                     // resources.configuration imply a previous call to context.resources, we only
221                     // want to report the resources.configuration error in such a case, and not
222                     // the context.resources error. To do that, we need to track the
223                     // context used for the resources.configuration call, so that we only
224                     // report an error for context.resources calls when there is no
225                     // resources.configuration error we are reporting that referenced the same
226                     // context
227                     if (configurationCall) {
228                         contextsReferencedFromResourcesConfigurationCall.add(contextExpression)
229                         context.report(
230                             LocalContextConfigurationRead,
231                             node,
232                             context.getNameLocation(node),
233                             "Reading Configuration using $LocalContextCurrentResourcesConfiguration"
234                         )
235                     } else {
236                         // context.resources call, so add to list of context.resources calls to
237                         // report after we analyze the file, to avoid double reporting as mentioned
238                         // above
239                         contextResourcesCalls.add(node)
240                     }
241                 }
242             }
243         }
244 
245     /**
246      * Given a resources.configuration expression, try and find the context expression that the
247      * resources comes from.
248      */
249     private fun findContextExpressionFromResourcesConfigurationExpression(
250         resources: UExpression
251     ): UExpression? {
252         return when (resources) {
253             // Still part of a qualified expression, e.g. context.resources
254             is UQualifiedReferenceExpression -> {
255                 if (!resources.isCallToGetResources()) return null
256                 // Return the receiver, e.g. `context` in the case of
257                 // `context.resources`
258                 resources.receiver.skipParenthesizedExprDown()
259             }
260             // Possible reference to a variable, e.g. val resources = context.resources,
261             // and this USimpleNameReferenceExpression is `resources`
262             is USimpleNameReferenceExpression -> {
263                 // If it is a property such as val resources = context.resources, find
264                 // the initializer
265                 val initializer =
266                     (resources.tryResolveUDeclaration() as? UVariable)?.uastInitializer
267                         ?: return null
268                 if (initializer !is UQualifiedReferenceExpression) return null
269                 if (!initializer.isCallToGetResources()) return null
270                 // Return the receiver, e.g. `context` in the case of
271                 // `context.resources`
272                 initializer.receiver.skipParenthesizedExprDown()
273             }
274             else -> null
275         }
276     }
277 
278     private fun UElement.isCallToGetConfiguration(): Boolean {
279         val resolved = tryResolve() as? PsiMethod ?: return false
280         return resolved.name == "getConfiguration" && resolved.isInPackageName(ResPackage)
281     }
282 
283     private fun UElement.isCallToGetResources(): Boolean {
284         val resolved = tryResolve() as? PsiMethod ?: return false
285         return resolved.name == "getResources" && resolved.isInPackageName(ContentPackage)
286     }
287 
288     companion object {
289         private const val LocalContextCurrentResources = "LocalContext.current.resources"
290         private const val LocalContextCurrentResourcesConfiguration =
291             "LocalContext.current.resources.configuration"
292         private const val LocalResourcesCurrent = "LocalResources.current"
293         private const val LocalConfigurationCurrent = "LocalConfiguration.current"
294         private val ContentPackage = Package("android.content")
295         private val ResPackage = Package("android.content.res")
296 
297         val LocalContextConfigurationRead =
298             Issue.create(
299                 "LocalContextConfigurationRead",
300                 "Reading Configuration using $LocalContextCurrentResourcesConfiguration",
301                 "Changes to the Configuration object will not cause LocalContext reads to be " +
302                     "invalidated, so you may end up with stale values when the Configuration " +
303                     "changes. Instead, use $LocalConfigurationCurrent to retrieve the " +
304                     "Configuration - this will recompose callers when the Configuration object " +
305                     "changes.",
306                 Category.CORRECTNESS,
307                 3,
308                 Severity.ERROR,
309                 Implementation(
310                     LocalContextResourcesConfigurationReadDetector::class.java,
311                     EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES)
312                 )
313             )
314 
315         val LocalContextResourcesRead =
316             Issue.create(
317                 "LocalContextResourcesRead",
318                 "Reading Resources using $LocalContextCurrentResources",
319                 "Changes to the Configuration object will not cause " +
320                     "$LocalContextCurrentResources reads to be invalidated, so calls to APIs such" +
321                     "as Resources.getString() will not be updated when the Configuration " +
322                     "changes. Instead, use $LocalResourcesCurrent to retrieve the Resources - " +
323                     "this will invalidate callers when the Configuration changes, to ensure that " +
324                     "these calls reflect the latest values.",
325                 Category.CORRECTNESS,
326                 3,
327                 Severity.WARNING,
328                 Implementation(
329                     LocalContextResourcesConfigurationReadDetector::class.java,
330                     EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES)
331                 )
332             )
333     }
334 }
335