From 0864a59cafefaf33a31f1a4b82ade12f8fd0179c Mon Sep 17 00:00:00 2001 From: "gh-worker-dd-mergequeue-cf854d[bot]" <244855004+gh-worker-dd-mergequeue-cf854d[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:33:46 +0000 Subject: [PATCH 1/3] Merge pull request #3383 from DataDog/aleksandr-gringauz/RUM-15877/add-fed2 RUM-15877: Add fed2 endpoint to DatadogSite Co-authored-by: aleksandr-gringauz --- dd-sdk-android-core/api/apiSurface | 1 + dd-sdk-android-core/api/dd-sdk-android-core.api | 1 + .../main/kotlin/com/datadog/android/DatadogSite.kt | 5 +++++ .../kotlin/com/datadog/android/DatadogSiteTest.kt | 5 +++++ .../android/flags/internal/DatadogSiteExtensions.kt | 3 ++- .../flags/internal/DatadogSiteExtensionsTest.kt | 12 ++++++++---- .../net/PrecomputedAssignmentsRequestFactoryTest.kt | 13 ++++++++++--- sample/kotlin/build.gradle.kts | 2 +- .../android/sample/webview/DatadogSiteExt.kt | 1 + sample/wear/build.gradle.kts | 2 +- 10 files changed, 35 insertions(+), 10 deletions(-) diff --git a/dd-sdk-android-core/api/apiSurface b/dd-sdk-android-core/api/apiSurface index 67f4f7815a..c6b806b96a 100644 --- a/dd-sdk-android-core/api/apiSurface +++ b/dd-sdk-android-core/api/apiSurface @@ -23,6 +23,7 @@ enum com.datadog.android.DatadogSite - AP1 - AP2 - US1_FED + - US2_FED - STAGING val intakeEndpoint: String class com.datadog.android._InternalProxy diff --git a/dd-sdk-android-core/api/dd-sdk-android-core.api b/dd-sdk-android-core/api/dd-sdk-android-core.api index 7b9f86b628..b206425a2f 100644 --- a/dd-sdk-android-core/api/dd-sdk-android-core.api +++ b/dd-sdk-android-core/api/dd-sdk-android-core.api @@ -64,6 +64,7 @@ public final class com/datadog/android/DatadogSite : java/lang/Enum { public static final field STAGING Lcom/datadog/android/DatadogSite; public static final field US1 Lcom/datadog/android/DatadogSite; public static final field US1_FED Lcom/datadog/android/DatadogSite; + public static final field US2_FED Lcom/datadog/android/DatadogSite; public static final field US3 Lcom/datadog/android/DatadogSite; public static final field US5 Lcom/datadog/android/DatadogSite; public final fun getIntakeEndpoint ()Ljava/lang/String; diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/DatadogSite.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/DatadogSite.kt index 26b4c2731b..fcb668b292 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/DatadogSite.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/DatadogSite.kt @@ -50,6 +50,11 @@ enum class DatadogSite private constructor(internal val siteName: String, privat */ US1_FED("us1_fed", "browser-intake-ddog-gov.com"), + /** + * The US2_FED site (FedRAMP compatible): [us2.ddog-gov.com](https://us2.ddog-gov.com). + */ + US2_FED("us2_fed", "browser-intake-us2-ddog-gov.com"), + /** * The STAGING site (internal usage only): [app.datad0g.com](https://app.datad0g.com). */ diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/DatadogSiteTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/DatadogSiteTest.kt index 7b58e40b83..a4127ff5c3 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/DatadogSiteTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/DatadogSiteTest.kt @@ -45,6 +45,11 @@ internal class DatadogSiteTest { assertThat(DatadogSite.US1_FED.intakeEndpoint).isEqualTo("https://browser-intake-ddog-gov.com") } + @Test + fun `M return intake endpoint W intakeEndpoint {US2-FED}`() { + assertThat(DatadogSite.US2_FED.intakeEndpoint).isEqualTo("https://browser-intake-us2-ddog-gov.com") + } + @Test fun `M return intake endpoint W intakeEndpoint {EU1}`() { assertThat(DatadogSite.EU1.intakeEndpoint).isEqualTo("https://browser-intake-datadoghq.eu") diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogSiteExtensions.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogSiteExtensions.kt index 918c8cd343..89e622d66e 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogSiteExtensions.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogSiteExtensions.kt @@ -25,7 +25,8 @@ internal fun DatadogSite.getFlagsEndpoint(customerDomain: String): String? { * @return Flags host string in format `.ff-cdn..`, or null if site not supported */ private fun DatadogSite.flagsHost(customerDomain: String): String? = when (this) { - DatadogSite.US1_FED -> null + DatadogSite.US1_FED, + DatadogSite.US2_FED -> null DatadogSite.STAGING -> "$customerDomain.ff-cdn.datad0g.com" DatadogSite.EU1 -> buildFlagsHostString(customerDomain, tld = "eu") // No site in the host, .eu TLD diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogSiteExtensionsTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogSiteExtensionsTest.kt index 93dbea8108..24a86d23e5 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogSiteExtensionsTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogSiteExtensionsTest.kt @@ -10,10 +10,10 @@ import com.datadog.android.DatadogSite import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.MethodSource @ExtendWith(ForgeExtension::class) @@ -56,10 +56,14 @@ internal class DatadogSiteExtensionsTest { // region getFlagsEndpoint - Error Cases - @Test - fun `M return null W getFlagsEndpoint() { unsupported site }`(@StringForgery customerDomain: String) { + @ParameterizedTest + @EnumSource(DatadogSite::class, names = ["US1_FED", "US2_FED"]) + fun `M return null W getFlagsEndpoint() { unsupported site }`( + site: DatadogSite, + @StringForgery customerDomain: String + ) { // When - val result = DatadogSite.US1_FED.getFlagsEndpoint(customerDomain) + val result = site.getFlagsEndpoint(customerDomain) // Then assertThat(result).isNull() diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt index e1cd5693a4..3a427c85f7 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt @@ -24,6 +24,8 @@ import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings @@ -49,7 +51,10 @@ internal class PrecomputedAssignmentsRequestFactoryTest { @BeforeEach fun `set up`(forge: Forge) { fakeDatadogContext = fakeDatadogContext.copy( - site = forge.aValueFrom(DatadogSite::class.java, exclude = listOf(DatadogSite.US1_FED)), + site = forge.aValueFrom( + DatadogSite::class.java, + exclude = listOf(DatadogSite.US1_FED, DatadogSite.US2_FED) + ), featuresContext = fakeDatadogContext.featuresContext + mapOf(Feature.RUM_FEATURE_NAME to mapOf("application_id" to fakeRumApplicationId.toString())) ) @@ -252,8 +257,10 @@ internal class PrecomputedAssignmentsRequestFactoryTest { // region create() - Error cases - @Test + @ParameterizedTest + @EnumSource(DatadogSite::class, names = ["US1_FED", "US2_FED"]) fun `M return null W create() { unsupported site and no custom endpoint }`( + site: DatadogSite, @StringForgery fakeTargetingKey: String ) { // Given @@ -262,7 +269,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { attributes = emptyMap() ) fakeDatadogContext = fakeDatadogContext.copy( - site = DatadogSite.US1_FED + site = site ) // When diff --git a/sample/kotlin/build.gradle.kts b/sample/kotlin/build.gradle.kts index cef6636b7d..0e90cb38ee 100644 --- a/sample/kotlin/build.gradle.kts +++ b/sample/kotlin/build.gradle.kts @@ -75,7 +75,7 @@ android { flavorDimensions += listOf("site") productFlavors { - val regions = arrayOf("us1", "us3", "us5", "us1_fed", "eu1", "ap1", "ap2", "staging") + val regions = arrayOf("us1", "us3", "us5", "us1_fed", "us2_fed", "eu1", "ap1", "ap2", "staging") regions.forEachIndexed { index, region -> register(region) { diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/DatadogSiteExt.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/DatadogSiteExt.kt index 4f98c094ba..3479feb5d0 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/DatadogSiteExt.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/DatadogSiteExt.kt @@ -32,5 +32,6 @@ private fun DatadogSite?.browserSite(): String { DatadogSite.AP1 -> "ap1.datadoghq.com" DatadogSite.AP2 -> "ap2.datadoghq.com" DatadogSite.US1_FED -> "ddog-gov.com" + DatadogSite.US2_FED -> "us2.ddog-gov.com" } } diff --git a/sample/wear/build.gradle.kts b/sample/wear/build.gradle.kts index eb9b8a84c0..e3ce5c4de2 100644 --- a/sample/wear/build.gradle.kts +++ b/sample/wear/build.gradle.kts @@ -38,7 +38,7 @@ android { flavorDimensions += listOf("site") productFlavors { - val regions = arrayOf("us1", "us3", "us5", "us1_fed", "eu1", "ap1", "ap2", "staging") + val regions = arrayOf("us1", "us3", "us5", "us1_fed", "us2_fed", "eu1", "ap1", "ap2", "staging") regions.forEachIndexed { index, region -> register(region) { From 6075149d1c0a7c90b289e2d1a764bfc14d2f2a80 Mon Sep 17 00:00:00 2001 From: Timur Valeev Date: Tue, 21 Apr 2026 21:17:05 +0100 Subject: [PATCH 2/3] RUM-15813: Skip method scan in LayoutNodeUtils reflection fallback --- detekt_custom_safe_calls.yml | 3 + .../compose/internal/utils/LayoutNodeUtils.kt | 97 +++++- .../internal/utils/FakeLayoutNodeUi.java | 34 -- .../utils/FakeLayoutNodeUiRelease.java | 35 -- .../internal/utils/LayoutNodeGetMethodTest.kt | 133 -------- .../internal/utils/LayoutNodeUtilsTest.kt | 314 +++++++++++++++++- 6 files changed, 395 insertions(+), 221 deletions(-) delete mode 100644 integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUi.java delete mode 100644 integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUiRelease.java delete mode 100644 integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeGetMethodTest.kt diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index 75cec0698b..37abb4d8ad 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -954,9 +954,11 @@ datadog: - "kotlin.collections.MutableMap.filterKeys(kotlin.Function1)" - "kotlin.collections.MutableMap.filterValues(kotlin.Function1)" - "kotlin.collections.MutableMap.forEach(kotlin.Function1)" + - "kotlin.collections.MutableMap.get(java.lang.Class)" - "kotlin.collections.MutableMap.get(kotlin.String)" - "kotlin.collections.MutableMap.get(kotlin.String?)" - "kotlin.collections.MutableMap.getOrElse(java.lang.Class, kotlin.Function0)" + - "kotlin.collections.MutableMap.getOrPut(java.lang.Class, kotlin.Function0)" - "kotlin.collections.MutableMap.getOrPut(kotlin.String, kotlin.Function0)" - "kotlin.collections.MutableMap.isEmpty()" - "kotlin.collections.MutableMap.isNotEmpty()" @@ -964,6 +966,7 @@ datadog: - "kotlin.collections.MutableMap.orEmpty()" - "kotlin.collections.MutableMap.map(kotlin.Function1)" - "kotlin.collections.MutableMap.mapValues(kotlin.Function1)" + - "kotlin.collections.MutableMap.put(java.lang.Class, kotlin.String?)" - "kotlin.collections.MutableMap.put(kotlin.Any?, kotlin.Any?)" - "kotlin.collections.MutableMap.put(kotlin.String, kotlin.Any)" - "kotlin.collections.MutableMap.put(kotlin.String, kotlin.Any?)" diff --git a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtils.kt b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtils.kt index ce6e1e147d..8d95f0e8dc 100644 --- a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtils.kt +++ b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtils.kt @@ -9,6 +9,7 @@ package com.datadog.android.compose.internal.utils import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.semantics.Role @@ -22,10 +23,11 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.compose.DatadogSemanticsPropertyKey import com.datadog.android.rum.RumAttributes.ACTION_TARGET_ROLE import com.datadog.android.rum.RumAttributes.ACTION_TARGET_SELECTED +import java.lang.reflect.Method internal class LayoutNodeUtils { - private var reflectionFallbackModeActivated = false + private val methodResolver = MethodResolver() @Suppress("NestedBlockDepth", "CyclomaticComplexMethod") fun resolveLayoutNode(node: LayoutNode): TargetNode? { @@ -99,33 +101,35 @@ internal class LayoutNodeUtils { } } - fun getLayoutNodeBoundsInWindow(node: LayoutNode): Rect? = if (reflectionFallbackModeActivated) { - getLayoutNodeBoundsInWindowReflection(node) - } else { - getLayoutNodeBoundsInWindowInternal(node) ?: getLayoutNodeBoundsInWindowReflection(node) + fun getLayoutNodeBoundsInWindow(node: LayoutNode): Rect? = when (methodResolver.state) { + MethodResolver.State.UNKNOWN -> { + getLayoutNodeBoundsInWindowInternal(node) ?: getLayoutNodeBoundsInWindowReflection(node) + } + + MethodResolver.State.MANGLING_FAILED -> getLayoutNodeBoundsInWindowReflection(node) + MethodResolver.State.REFLECTION_FAILED -> getLayoutNodeBoundsInWindowInternal(node) } - private fun getLayoutNodeBoundsInWindowInternal(node: LayoutNode): Rect? = runSafe( + internal fun getLayoutNodeBoundsInWindowInternal(node: LayoutNode): Rect? = runSafe( "getLayoutNodeBoundsInWindow" ) { node.layoutDelegate.outerCoordinator.coordinates.boundsInWindow() } - private fun getLayoutNodeBoundsInWindowReflection(node: LayoutNode) = runSafe( + internal fun getLayoutNodeBoundsInWindowReflection(node: LayoutNode) = runSafe( "getLayoutNodeBoundsInWindow[reflection]" ) { // TODO RUM-13454 Update compose bom and remove this method - reflectionFallbackModeActivated = true - val coordinates = node.getMethod("getLayoutDelegate") - ?.getMethod("getOuterCoordinator") - ?.getMethod("getCoordinates") - - @Suppress("UnsafeThirdPartyFunctionCall") // it's okay if exception will be thrown here - Class.forName("androidx.compose.ui.layout.LayoutCoordinatesKt") - .getMethod("boundsInWindow", Class.forName("androidx.compose.ui.layout.LayoutCoordinates")) - .invoke(null, coordinates) as? Rect + methodResolver.state = MethodResolver.State.MANGLING_FAILED + val coordinates = node.invokeWithReflection("getLayoutDelegate") + ?.invokeWithReflection("getOuterCoordinator") + ?.invokeWithReflection("getCoordinates") as? LayoutCoordinates + coordinates?.boundsInWindow() } - private fun Any.getMethod(prefix: String): Any? { - return this.javaClass.methods.firstOrNull { it.name == prefix || it.name.startsWith("$prefix$") } + @Suppress("UnsafeThirdPartyFunctionCall") // runSafe in the caller swallows any Throwable + private fun Any.invokeWithReflection(prefix: String): Any? { + if (methodResolver.state == MethodResolver.State.REFLECTION_FAILED) return null + return methodResolver + .findMethod(javaClass, prefix) ?.invoke(this) } @@ -153,6 +157,35 @@ internal class LayoutNodeUtils { val customAttributes: Map = mapOf() ) + internal class MethodResolver { + // RUM-15813: Kotlin internal accessors in androidx.compose.ui are JVM-mangled with + // a module suffix (plain / "$ui" / "$ui_release"). The mangling is stable per class, + // but NOT necessarily the same across classes in the reflection chain: e.g. on Compose + // UI 1.10 LayoutNode.getLayoutDelegate$ui is mangled while LayoutNodeLayoutDelegate + // exposes getOuterCoordinator without a suffix. We therefore cache the resolved suffix + // per owner-class rather than once globally. + // + enum class State { + UNKNOWN, // — Try internal mangling resolution, if fails - reflection. + MANGLING_FAILED, // Mangling resolution failed - allowing reflection attempt + REFLECTION_FAILED // Reflection resolution failed - switching back to mangling resolution (even if it fails) + } + + var state: State = State.UNKNOWN + set(value) { + if (value.ordinal > field.ordinal) { + field = value + } + } + + val classPrefixMethodsCache: MutableMap, MutableMap> = mutableMapOf() + + fun findMethod(klass: Class<*>, prefix: String): Method? = + classPrefixMethodsCache + .resolveMethod(klass, prefix) + .also { if (it == null) state = State.REFLECTION_FAILED } + } + companion object { private const val CLASS_NAME_CLICKABLE_ELEMENT = "androidx.compose.foundation.ClickableElement" @@ -168,5 +201,33 @@ internal class LayoutNodeUtils { "androidx.compose.foundation.gestures.ScrollableElement" private const val CLASS_NAME_SELECTABLE_ELEMENT = "androidx.compose.foundation.selection.SelectableElement" + + // Empty suffix covers public accessors; "$ui" and "$ui_release" cover Kotlin-internal + // accessors in the androidx.compose.ui module (the exact suffix depends on build + // configuration). Ordered by likelihood — public first, release build second. + private val SUPPORTED_MANGLING_SUFFIXES = listOf("", "\$ui_release", "\$ui") + + private fun MutableMap, MutableMap>.resolveMethod( + klass: Class<*>, + methodPrefix: String + ): Method? { + val klassCache: MutableMap = getOrPut(klass) { mutableMapOf() } + + if (!klassCache.containsKey(methodPrefix)) { + klassCache[methodPrefix] = searchManglings(klass, methodPrefix) + } + + return klassCache[methodPrefix] + } + + private fun searchManglings(klass: Class<*>, prefix: String): Method? = SUPPORTED_MANGLING_SUFFIXES + .firstNotNullOfOrNull { + try { + @Suppress("UnsafeThirdPartyFunctionCall") // NoSuchMethodException is expected here + klass.getMethod("$prefix$it") + } catch (@Suppress("SwallowedException") _: NoSuchMethodException) { + null + } + } } } diff --git a/integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUi.java b/integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUi.java deleted file mode 100644 index 46a2c9b7b6..0000000000 --- a/integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUi.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.compose.internal.utils; - -/** - * Fake object used in tests to simulate an internal Compose class that exposes - * methods with the {@code $ui} module-suffix naming convention (the JVM-mangled - * name for {@code internal} members in the {@code androidx.compose.ui} module). - * Used to verify the reflection fallback in {@link LayoutNodeUtils}. - */ -public class FakeLayoutNodeUi { - - public final Object value; - - public FakeLayoutNodeUi(Object value) { - this.value = value; - } - - public Object getLayoutDelegate$ui() { - return value; - } - - public Object getOuterCoordinator$ui() { - return value; - } - - public Object getCoordinates$ui() { - return value; - } -} diff --git a/integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUiRelease.java b/integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUiRelease.java deleted file mode 100644 index b56456c9cb..0000000000 --- a/integrations/dd-sdk-android-compose/src/test/java/com/datadog/android/compose/internal/utils/FakeLayoutNodeUiRelease.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.compose.internal.utils; - -/** - * Fake object used in tests to simulate an internal Compose class that exposes - * methods with the {@code $ui_release} module-suffix naming convention (the JVM-mangled - * name for {@code internal} members in the release variant of the - * {@code androidx.compose.ui} module). - * Used to verify the reflection fallback in {@link LayoutNodeUtils}. - */ -public class FakeLayoutNodeUiRelease { - - public final Object value; - - public FakeLayoutNodeUiRelease(Object value) { - this.value = value; - } - - public Object getLayoutDelegate$ui_release() { - return value; - } - - public Object getOuterCoordinator$ui_release() { - return value; - } - - public Object getCoordinates$ui_release() { - return value; - } -} diff --git a/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeGetMethodTest.kt b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeGetMethodTest.kt deleted file mode 100644 index 9b428b3bb2..0000000000 --- a/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeGetMethodTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.compose.internal.utils - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.lang.reflect.Method - -/** - * Tests for the [LayoutNodeUtils.getMethod] reflection helper that resolves - * Kotlin-internal method names regardless of module-suffix mangling. - * - * Compose internal properties are compiled to JVM methods whose names are mangled - * with a module identifier suffix, e.g. {@code getLayoutDelegate$ui} (debug) or - * {@code getLayoutDelegate$ui_release} (release). [FakeLayoutNodeUi] and - * [FakeLayoutNodeUiRelease] simulate these two naming conventions. - */ -internal class LayoutNodeGetMethodTest { - - private val testedLayoutNodeUtils = LayoutNodeUtils() - - // getMethod is private fun Any.getMethod(prefix: String): Any? compiled as an - // instance method with signature getMethod(Object, String) in the JVM. - private val getMethodFn: Method = LayoutNodeUtils::class.java - .getDeclaredMethod("getMethod", Any::class.java, String::class.java) - .also { it.isAccessible = true } - - // region $ui suffix - - @Test - fun `M find method W getMethod {method has dollar ui suffix}`() { - // Given - val expected = Any() - val fakeNode = FakeLayoutNodeUi(expected) - - // When - val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getLayoutDelegate") - - // Then - assertThat(result).isSameAs(expected) - } - - @Test - fun `M find method W getMethod {outer coordinator method has dollar ui suffix}`() { - // Given - val expected = Any() - val fakeNode = FakeLayoutNodeUi(expected) - - // When - val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getOuterCoordinator") - - // Then - assertThat(result).isSameAs(expected) - } - - @Test - fun `M find method W getMethod {coordinates method has dollar ui suffix}`() { - // Given - val expected = Any() - val fakeNode = FakeLayoutNodeUi(expected) - - // When - val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getCoordinates") - - // Then - assertThat(result).isSameAs(expected) - } - - // endregion - - // region $ui_release suffix - - @Test - fun `M find method W getMethod {method has dollar ui_release suffix}`() { - // Given - val expected = Any() - val fakeNode = FakeLayoutNodeUiRelease(expected) - - // When - val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getLayoutDelegate") - - // Then - assertThat(result).isSameAs(expected) - } - - @Test - fun `M find method W getMethod {outer coordinator method has dollar ui_release suffix}`() { - // Given - val expected = Any() - val fakeNode = FakeLayoutNodeUiRelease(expected) - - // When - val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getOuterCoordinator") - - // Then - assertThat(result).isSameAs(expected) - } - - @Test - fun `M find method W getMethod {coordinates method has dollar ui_release suffix}`() { - // Given - val expected = Any() - val fakeNode = FakeLayoutNodeUiRelease(expected) - - // When - val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getCoordinates") - - // Then - assertThat(result).isSameAs(expected) - } - - // endregion - - // region no match - - @Test - fun `M return null W getMethod {no matching method}`() { - // Given - val fakeNode = Any() - - // When - val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getLayoutDelegate") - - // Then - assertThat(result).isNull() - } - - // endregion -} diff --git a/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtilsTest.kt b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtilsTest.kt index ed8499a37b..13b408578f 100644 --- a/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtilsTest.kt +++ b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtilsTest.kt @@ -15,9 +15,12 @@ import androidx.compose.foundation.selection.SelectableElement import androidx.compose.foundation.selection.ToggleableElement import androidx.compose.foundation.selection.TriStateToggleableElement import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.ModifierInfo import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.LayoutNodeLayoutDelegate +import androidx.compose.ui.node.NodeCoordinator import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsConfiguration import androidx.compose.ui.semantics.SemanticsModifier @@ -46,11 +49,15 @@ import org.mockito.ArgumentMatchers.isA import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.same +import org.mockito.kotlin.spy +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -66,7 +73,7 @@ import java.util.stream.Stream @MockitoSettings(strictness = Strictness.LENIENT) class LayoutNodeUtilsTest { - private val testedLayoutNodeUtils = LayoutNodeUtils() + private val testedLayoutNodeUtils = spy(LayoutNodeUtils()) @Mock lateinit var mockSdkCore: FeatureSdkCore @@ -218,6 +225,274 @@ class LayoutNodeUtilsTest { // endregion + // region Reflection Fallback State Machine + + @Test + fun `M keep skipping reflection path W getLayoutNodeBoundsInWindow() {repeated, internal succeeds}`() { + // Given + val mockNode = mockLayoutNodeWithValidInternalPath() + + // When + repeat(5) { testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) } + + // Then + verify(testedLayoutNodeUtils, times(5)).getLayoutNodeBoundsInWindowInternal(mockNode) + verify(testedLayoutNodeUtils, never()).getLayoutNodeBoundsInWindowReflection(any()) + } + + @Test + fun `M use reflection path only W getLayoutNodeBoundsInWindow() {reflection already activated}`() { + // Given + testedLayoutNodeUtils.methodResolver().state = LayoutNodeUtils.MethodResolver.State.MANGLING_FAILED + val mockNode = mock() + + // When + repeat(3) { testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) } + + // Then + verify(testedLayoutNodeUtils, times(3)).getLayoutNodeBoundsInWindowReflection(mockNode) + verify(testedLayoutNodeUtils, never()).getLayoutNodeBoundsInWindowInternal(any()) + } + + @Test + fun `M activate reflection without giving up W getBoundsInWindow() {internal fails, reflection ok}`() { + // Given + // mock() returns null for layoutDelegate, so internal path fails with NPE + // swallowed by runSafe; reflection path then runs against the mock's JVM class + // (inherits LayoutNode accessors), so method lookup succeeds and state stays MANGLING_FAILED. + val mockNode = mock() + + // When + testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) + + // Then + val resolver = testedLayoutNodeUtils.methodResolver() + assertThat(resolver.state).isEqualTo(LayoutNodeUtils.MethodResolver.State.MANGLING_FAILED) + assertThat(resolver.classPrefixMethodsCache) + .containsKey(mockNode.javaClass) + assertThat(resolver.classPrefixMethodsCache[mockNode.javaClass]) + .doesNotContainValue(null) + } + + @Test + fun `M not grow suffix cache W getLayoutNodeBoundsInWindow() {repeated reflection calls}`() { + // Given + // Force reflection path so all three levels (LayoutNode, LayoutNodeLayoutDelegate, + // NodeCoordinator) are traversed and cached — the maximum cache size for this chain. + testedLayoutNodeUtils.methodResolver().state = LayoutNodeUtils.MethodResolver.State.MANGLING_FAILED + val mockNode = mockLayoutNodeWithValidInternalPath() + testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) + val cache = testedLayoutNodeUtils.methodResolver().classPrefixMethodsCache + val sizeAfterFirstCall = cache.size + + // When + repeat(10) { testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) } + + // Then + assertThat(sizeAfterFirstCall).isEqualTo(EXPECTED_REFLECTION_CHAIN_LENGTH) + assertThat(cache).hasSize(sizeAfterFirstCall) + } + + @Test + fun `M transition to REFLECTION_FAILED W findMethod() {no method found}`( + @StringForgery fakePrefix: String + ) { + // Given + val methodResolver = LayoutNodeUtils.MethodResolver() + + // When + val result = methodResolver.findMethod(Any::class.java, fakePrefix) + + // Then + assertThat(result).isNull() + assertThat(methodResolver.state).isEqualTo(LayoutNodeUtils.MethodResolver.State.REFLECTION_FAILED) + } + + @Test + fun `M use internal path only W getLayoutNodeBoundsInWindow() {reflection gave up}`() { + // Given + testedLayoutNodeUtils.methodResolver().state = LayoutNodeUtils.MethodResolver.State.REFLECTION_FAILED + val mockNode = mock() + + // When + repeat(3) { testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) } + + // Then + verify(testedLayoutNodeUtils, times(3)).getLayoutNodeBoundsInWindowInternal(mockNode) + verify(testedLayoutNodeUtils, never()).getLayoutNodeBoundsInWindowReflection(any()) + } + + @Test + fun `M stay on internal W getLayoutNodeBoundsInWindow() {first call resolved via internal, repeated}`() { + // Given + // First call resolves through the internal path — state stays UNKNOWN, cache stays empty. + val mockNode = mockLayoutNodeWithValidInternalPath() + testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) + + // When + repeat(10) { testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) } + + // Then + // Subsequent calls never fall back to reflection. + verify(testedLayoutNodeUtils, times(11)).getLayoutNodeBoundsInWindowInternal(mockNode) + verify(testedLayoutNodeUtils, never()).getLayoutNodeBoundsInWindowReflection(any()) + val resolver = testedLayoutNodeUtils.methodResolver() + assertThat(resolver.state).isEqualTo(LayoutNodeUtils.MethodResolver.State.UNKNOWN) + assertThat(resolver.classPrefixMethodsCache).isEmpty() + } + + @Test + fun `M stay on reflection and hit cache W getBoundsInWindow() {first call resolved via reflection, repeated}`() { + // Given + // First call: internal fails (mock returns null layoutDelegate → NPE swallowed + // by runSafe), so getLayoutNodeBoundsInWindow falls back to reflection. Reflection + // resolves the mangled accessors via the mock's JVM class and caches them. + // State transitions to MANGLING_FAILED. + val mockNode = mock() + testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) + val cacheAfterFirstCall = testedLayoutNodeUtils.methodResolver().classPrefixMethodsCache.toMap() + + // When + repeat(10) { testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) } + + // Then + // State=MANGLING_FAILED now routes straight to reflection — internal is not retried. + // Reflection wrapper keeps being invoked, but findMethod short-circuits on the cached + // entries, so the cache never grows. + verify(testedLayoutNodeUtils, times(1)).getLayoutNodeBoundsInWindowInternal(mockNode) + verify(testedLayoutNodeUtils, times(11)).getLayoutNodeBoundsInWindowReflection(mockNode) + assertThat(testedLayoutNodeUtils.methodResolver().classPrefixMethodsCache) + .isEqualTo(cacheAfterFirstCall) + } + + @Test + fun `M not downgrade state W MethodResolver#state {REFLECTION_FAILED cannot be lowered}`() { + // Given + val methodResolver = LayoutNodeUtils.MethodResolver() + methodResolver.state = LayoutNodeUtils.MethodResolver.State.REFLECTION_FAILED + + // When + methodResolver.state = LayoutNodeUtils.MethodResolver.State.UNKNOWN + methodResolver.state = LayoutNodeUtils.MethodResolver.State.MANGLING_FAILED + + // Then + assertThat(methodResolver.state) + .isEqualTo(LayoutNodeUtils.MethodResolver.State.REFLECTION_FAILED) + } + + @Test + fun `M not downgrade state W MethodResolver#state {MANGLING_FAILED cannot revert to UNKNOWN}`() { + // Given + val methodResolver = LayoutNodeUtils.MethodResolver() + methodResolver.state = LayoutNodeUtils.MethodResolver.State.MANGLING_FAILED + + // When + methodResolver.state = LayoutNodeUtils.MethodResolver.State.UNKNOWN + + // Then + assertThat(methodResolver.state) + .isEqualTo(LayoutNodeUtils.MethodResolver.State.MANGLING_FAILED) + } + + @Test + fun `M keep state UNKNOWN W getLayoutNodeBoundsInWindow() {internal path succeeds}`() { + // Given + val mockNode = mockLayoutNodeWithValidInternalPath() + + // When + testedLayoutNodeUtils.getLayoutNodeBoundsInWindow(mockNode) + + // Then + assertThat(testedLayoutNodeUtils.methodResolver().state) + .isEqualTo(LayoutNodeUtils.MethodResolver.State.UNKNOWN) + } + + @Test + fun `M prefer plain name W findMethod() {plain and mangled variants coexist}`() { + // Given + // SuffixFixture declares foo(), foo$ui_release() and foo$ui() — all three suffixes + // in SUPPORTED_MANGLING_SUFFIXES. Plain is first in the list, so it must win. + val methodResolver = LayoutNodeUtils.MethodResolver() + + // When + val result = methodResolver.findMethod(SuffixFixture::class.java, "foo") + + // Then + assertThat(result?.name).isEqualTo("foo") + } + + @Test + fun `M prefer ui_release over ui W findMethod() {no plain variant}`() { + // Given + // SuffixFixture declares bar$ui_release() and bar$ui() but no plain bar(). + // $ui_release precedes $ui in SUPPORTED_MANGLING_SUFFIXES, so it must win. + val methodResolver = LayoutNodeUtils.MethodResolver() + + // When + val result = methodResolver.findMethod(SuffixFixture::class.java, "bar") + + // Then + assertThat(result?.name).isEqualTo("bar\$ui_release") + } + + @Test + fun `M resolve ui suffix W findMethod() {only ui variant exists}`() { + // Given + // SuffixFixture declares only bazz$ui() — the last fallback in SUPPORTED_MANGLING_SUFFIXES. + val methodResolver = LayoutNodeUtils.MethodResolver() + + // When + val result = methodResolver.findMethod(SuffixFixture::class.java, "bazz") + + // Then + assertThat(result?.name).isEqualTo("bazz\$ui") + } + + @Test + fun `M return cached value W findMethod() {prefix already resolved}`() { + // Given + // Realistic context: findMethod is always called from the reflection flow which has + // already transitioned state to MANGLING_FAILED. Seed the cache with a pre-resolved + // method for a prefix ("bogus") that would NOT resolve via reflection — if findMethod + // ignored the cache and re-searched, it would return null (and downgrade to + // REFLECTION_FAILED). + val methodResolver = LayoutNodeUtils.MethodResolver() + methodResolver.state = LayoutNodeUtils.MethodResolver.State.MANGLING_FAILED + val knownMethod = SuffixFixture::class.java.getMethod("foo") + methodResolver.classPrefixMethodsCache[SuffixFixture::class.java] = + mutableMapOf("bogus" to knownMethod) + + // When + val result = methodResolver.findMethod(SuffixFixture::class.java, "bogus") + + // Then + assertThat(result).isSameAs(knownMethod) + assertThat(methodResolver.state) + .isEqualTo(LayoutNodeUtils.MethodResolver.State.MANGLING_FAILED) + } + + @Test + fun `M return cached null and flag REFLECTION_FAILED W findMethod() {prefix previously unresolved}`() { + // Given + // A null cached value means "we already tried and failed" — must not re-run searchManglings, + // even when the method actually exists on the class. State still transitions to + // REFLECTION_FAILED because findMethod's post-condition fires on any null result. + val methodResolver = LayoutNodeUtils.MethodResolver() + methodResolver.state = LayoutNodeUtils.MethodResolver.State.MANGLING_FAILED + methodResolver.classPrefixMethodsCache[SuffixFixture::class.java] = + mutableMapOf("foo" to null) + + // When + val result = methodResolver.findMethod(SuffixFixture::class.java, "foo") + + // Then + assertThat(result).isNull() + assertThat(methodResolver.state) + .isEqualTo(LayoutNodeUtils.MethodResolver.State.REFLECTION_FAILED) + } + + // endregion + // region Private private fun mockLayoutNodeWithModifiers( @@ -245,6 +520,28 @@ class LayoutNodeUtilsTest { } } + private fun LayoutNodeUtils.methodResolver(): LayoutNodeUtils.MethodResolver = + getFieldValue("methodResolver") + + private fun mockLayoutNodeWithValidInternalPath(): LayoutNode { + // Stub the full internal chain so that + // node.layoutDelegate.outerCoordinator.coordinates.boundsInWindow() returns a Rect. + // With parentLayoutCoordinates defaulting to null and localBoundingBoxOf returning + // Rect.Zero, boundsInWindow early-returns Rect.Zero without further chain access. + val mockLayoutCoordinates = mock { + whenever(it.localBoundingBoxOf(any(), any())) doReturn Rect.Zero + } + val mockOuterCoordinator = mock { + whenever(it.coordinates) doReturn mockLayoutCoordinates + } + val mockLayoutDelegate = mock { + whenever(it.outerCoordinator) doReturn mockOuterCoordinator + } + return mock { + whenever(it.layoutDelegate) doReturn mockLayoutDelegate + } + } + private fun mockLegacyLayoutNode( tagName: String, isClickable: Boolean = false, @@ -286,6 +583,9 @@ class LayoutNodeUtilsTest { */ private const val DEFAULT_INSTANCE_NAME = "_dd.sdk_core.default" + // LayoutNode -> LayoutNodeLayoutDelegate -> NodeCoordinator + private const val EXPECTED_REFLECTION_CHAIN_LENGTH = 3 + @JvmStatic fun clickableModifierElements(): Stream = Stream.of( ModifierTestCase("TriStateToggleableElement", TriStateToggleableElement()), @@ -301,4 +601,16 @@ class LayoutNodeUtilsTest { ModifierTestCase("ScrollableElement", ScrollableElement()) ) } + + // Method names mirror the JVM-mangled accessors emitted for Kotlin `internal` members + // in the androidx.compose.ui module. Used to exercise SUPPORTED_MANGLING_SUFFIXES resolution. + @Suppress("FunctionName", "unused") + internal class SuffixFixture { + fun foo() = Unit + fun `foo$ui_release`() = Unit + fun `foo$ui`() = Unit + fun `bar$ui_release`() = Unit + fun `bar$ui`() = Unit + fun `bazz$ui`() = Unit + } } From 2b36e9990f6880a2384a91506c56cbfb9a82af27 Mon Sep 17 00:00:00 2001 From: Francisco Veiga Date: Thu, 23 Apr 2026 17:00:45 +0100 Subject: [PATCH 3/3] Prepare release 3.9.1 --- CHANGELOG.md | 5 +++++ .../main/kotlin/com/datadog/gradle/config/AndroidConfig.kt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c85fade03..762f32bbf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 3.9.1 / 2026-04-23 + +* [BUGFIX] Cache reflection lookups in `LayoutNodeUtils`. See [#3381](https://github.com/DataDog/dd-sdk-android/pull/3381) +* [IMPROVEMENT] Add `fed2` endpoint to `DatadogSite`. See [#3391](https://github.com/DataDog/dd-sdk-android/pull/3391) + # 3.9.0 / 2026-04-16 * [FEATURE] Track GraphQL errors. See [#3275](https://github.com/DataDog/dd-sdk-android/pull/3275) diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/config/AndroidConfig.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/config/AndroidConfig.kt index 0c37db2e6a..24bece9590 100644 --- a/buildSrc/src/main/kotlin/com/datadog/gradle/config/AndroidConfig.kt +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/config/AndroidConfig.kt @@ -19,7 +19,7 @@ object AndroidConfig { const val MIN_SDK_FOR_AUTO = 29 const val BUILD_TOOLS_VERSION = "36.0.0" - val VERSION = Version(3, 9, 0, Version.Type.Release) + val VERSION = Version(3, 9, 1, Version.Type.Release) } // TODO RUM-628 Switch to Java 17 bytecode