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