Skip to content

Commit 6075149

Browse files
committed
RUM-15813: Skip method scan in LayoutNodeUtils reflection fallback
1 parent f47ab32 commit 6075149

6 files changed

Lines changed: 395 additions & 221 deletions

File tree

detekt_custom_safe_calls.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,16 +954,19 @@ datadog:
954954
- "kotlin.collections.MutableMap.filterKeys(kotlin.Function1)"
955955
- "kotlin.collections.MutableMap.filterValues(kotlin.Function1)"
956956
- "kotlin.collections.MutableMap.forEach(kotlin.Function1)"
957+
- "kotlin.collections.MutableMap.get(java.lang.Class)"
957958
- "kotlin.collections.MutableMap.get(kotlin.String)"
958959
- "kotlin.collections.MutableMap.get(kotlin.String?)"
959960
- "kotlin.collections.MutableMap.getOrElse(java.lang.Class, kotlin.Function0)"
961+
- "kotlin.collections.MutableMap.getOrPut(java.lang.Class, kotlin.Function0)"
960962
- "kotlin.collections.MutableMap.getOrPut(kotlin.String, kotlin.Function0)"
961963
- "kotlin.collections.MutableMap.isEmpty()"
962964
- "kotlin.collections.MutableMap.isNotEmpty()"
963965
- "kotlin.collections.MutableMap.iterator()"
964966
- "kotlin.collections.MutableMap.orEmpty()"
965967
- "kotlin.collections.MutableMap.map(kotlin.Function1)"
966968
- "kotlin.collections.MutableMap.mapValues(kotlin.Function1)"
969+
- "kotlin.collections.MutableMap.put(java.lang.Class, kotlin.String?)"
967970
- "kotlin.collections.MutableMap.put(kotlin.Any?, kotlin.Any?)"
968971
- "kotlin.collections.MutableMap.put(kotlin.String, kotlin.Any)"
969972
- "kotlin.collections.MutableMap.put(kotlin.String, kotlin.Any?)"

integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtils.kt

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
package com.datadog.android.compose.internal.utils
1010

1111
import androidx.compose.ui.geometry.Rect
12+
import androidx.compose.ui.layout.LayoutCoordinates
1213
import androidx.compose.ui.layout.boundsInWindow
1314
import androidx.compose.ui.node.LayoutNode
1415
import androidx.compose.ui.semantics.Role
@@ -22,10 +23,11 @@ import com.datadog.android.api.feature.FeatureSdkCore
2223
import com.datadog.android.compose.DatadogSemanticsPropertyKey
2324
import com.datadog.android.rum.RumAttributes.ACTION_TARGET_ROLE
2425
import com.datadog.android.rum.RumAttributes.ACTION_TARGET_SELECTED
26+
import java.lang.reflect.Method
2527

2628
internal class LayoutNodeUtils {
2729

28-
private var reflectionFallbackModeActivated = false
30+
private val methodResolver = MethodResolver()
2931

3032
@Suppress("NestedBlockDepth", "CyclomaticComplexMethod")
3133
fun resolveLayoutNode(node: LayoutNode): TargetNode? {
@@ -99,33 +101,35 @@ internal class LayoutNodeUtils {
99101
}
100102
}
101103

102-
fun getLayoutNodeBoundsInWindow(node: LayoutNode): Rect? = if (reflectionFallbackModeActivated) {
103-
getLayoutNodeBoundsInWindowReflection(node)
104-
} else {
105-
getLayoutNodeBoundsInWindowInternal(node) ?: getLayoutNodeBoundsInWindowReflection(node)
104+
fun getLayoutNodeBoundsInWindow(node: LayoutNode): Rect? = when (methodResolver.state) {
105+
MethodResolver.State.UNKNOWN -> {
106+
getLayoutNodeBoundsInWindowInternal(node) ?: getLayoutNodeBoundsInWindowReflection(node)
107+
}
108+
109+
MethodResolver.State.MANGLING_FAILED -> getLayoutNodeBoundsInWindowReflection(node)
110+
MethodResolver.State.REFLECTION_FAILED -> getLayoutNodeBoundsInWindowInternal(node)
106111
}
107112

108-
private fun getLayoutNodeBoundsInWindowInternal(node: LayoutNode): Rect? = runSafe(
113+
internal fun getLayoutNodeBoundsInWindowInternal(node: LayoutNode): Rect? = runSafe(
109114
"getLayoutNodeBoundsInWindow"
110115
) { node.layoutDelegate.outerCoordinator.coordinates.boundsInWindow() }
111116

112-
private fun getLayoutNodeBoundsInWindowReflection(node: LayoutNode) = runSafe(
117+
internal fun getLayoutNodeBoundsInWindowReflection(node: LayoutNode) = runSafe(
113118
"getLayoutNodeBoundsInWindow[reflection]"
114119
) {
115120
// TODO RUM-13454 Update compose bom and remove this method
116-
reflectionFallbackModeActivated = true
117-
val coordinates = node.getMethod("getLayoutDelegate")
118-
?.getMethod("getOuterCoordinator")
119-
?.getMethod("getCoordinates")
120-
121-
@Suppress("UnsafeThirdPartyFunctionCall") // it's okay if exception will be thrown here
122-
Class.forName("androidx.compose.ui.layout.LayoutCoordinatesKt")
123-
.getMethod("boundsInWindow", Class.forName("androidx.compose.ui.layout.LayoutCoordinates"))
124-
.invoke(null, coordinates) as? Rect
121+
methodResolver.state = MethodResolver.State.MANGLING_FAILED
122+
val coordinates = node.invokeWithReflection("getLayoutDelegate")
123+
?.invokeWithReflection("getOuterCoordinator")
124+
?.invokeWithReflection("getCoordinates") as? LayoutCoordinates
125+
coordinates?.boundsInWindow()
125126
}
126127

127-
private fun Any.getMethod(prefix: String): Any? {
128-
return this.javaClass.methods.firstOrNull { it.name == prefix || it.name.startsWith("$prefix$") }
128+
@Suppress("UnsafeThirdPartyFunctionCall") // runSafe in the caller swallows any Throwable
129+
private fun Any.invokeWithReflection(prefix: String): Any? {
130+
if (methodResolver.state == MethodResolver.State.REFLECTION_FAILED) return null
131+
return methodResolver
132+
.findMethod(javaClass, prefix)
129133
?.invoke(this)
130134
}
131135

@@ -153,6 +157,35 @@ internal class LayoutNodeUtils {
153157
val customAttributes: Map<String, Any?> = mapOf()
154158
)
155159

160+
internal class MethodResolver {
161+
// RUM-15813: Kotlin internal accessors in androidx.compose.ui are JVM-mangled with
162+
// a module suffix (plain / "$ui" / "$ui_release"). The mangling is stable per class,
163+
// but NOT necessarily the same across classes in the reflection chain: e.g. on Compose
164+
// UI 1.10 LayoutNode.getLayoutDelegate$ui is mangled while LayoutNodeLayoutDelegate
165+
// exposes getOuterCoordinator without a suffix. We therefore cache the resolved suffix
166+
// per owner-class rather than once globally.
167+
//
168+
enum class State {
169+
UNKNOWN, // — Try internal mangling resolution, if fails - reflection.
170+
MANGLING_FAILED, // Mangling resolution failed - allowing reflection attempt
171+
REFLECTION_FAILED // Reflection resolution failed - switching back to mangling resolution (even if it fails)
172+
}
173+
174+
var state: State = State.UNKNOWN
175+
set(value) {
176+
if (value.ordinal > field.ordinal) {
177+
field = value
178+
}
179+
}
180+
181+
val classPrefixMethodsCache: MutableMap<Class<*>, MutableMap<String, Method?>> = mutableMapOf()
182+
183+
fun findMethod(klass: Class<*>, prefix: String): Method? =
184+
classPrefixMethodsCache
185+
.resolveMethod(klass, prefix)
186+
.also { if (it == null) state = State.REFLECTION_FAILED }
187+
}
188+
156189
companion object {
157190
private const val CLASS_NAME_CLICKABLE_ELEMENT =
158191
"androidx.compose.foundation.ClickableElement"
@@ -168,5 +201,33 @@ internal class LayoutNodeUtils {
168201
"androidx.compose.foundation.gestures.ScrollableElement"
169202
private const val CLASS_NAME_SELECTABLE_ELEMENT =
170203
"androidx.compose.foundation.selection.SelectableElement"
204+
205+
// Empty suffix covers public accessors; "$ui" and "$ui_release" cover Kotlin-internal
206+
// accessors in the androidx.compose.ui module (the exact suffix depends on build
207+
// configuration). Ordered by likelihood — public first, release build second.
208+
private val SUPPORTED_MANGLING_SUFFIXES = listOf("", "\$ui_release", "\$ui")
209+
210+
private fun MutableMap<Class<*>, MutableMap<String, Method?>>.resolveMethod(
211+
klass: Class<*>,
212+
methodPrefix: String
213+
): Method? {
214+
val klassCache: MutableMap<String, Method?> = getOrPut(klass) { mutableMapOf() }
215+
216+
if (!klassCache.containsKey(methodPrefix)) {
217+
klassCache[methodPrefix] = searchManglings(klass, methodPrefix)
218+
}
219+
220+
return klassCache[methodPrefix]
221+
}
222+
223+
private fun searchManglings(klass: Class<*>, prefix: String): Method? = SUPPORTED_MANGLING_SUFFIXES
224+
.firstNotNullOfOrNull {
225+
try {
226+
@Suppress("UnsafeThirdPartyFunctionCall") // NoSuchMethodException is expected here
227+
klass.getMethod("$prefix$it")
228+
} catch (@Suppress("SwallowedException") _: NoSuchMethodException) {
229+
null
230+
}
231+
}
171232
}
172233
}

integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUi.java

Lines changed: 0 additions & 34 deletions
This file was deleted.

integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUiRelease.java

Lines changed: 0 additions & 35 deletions
This file was deleted.

integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeGetMethodTest.kt

Lines changed: 0 additions & 133 deletions
This file was deleted.

0 commit comments

Comments
 (0)