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