Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions dd-sdk-android-core/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum com.datadog.android.DatadogSite
- AP1
- AP2
- US1_FED
- US2_FED
- STAGING
val intakeEndpoint: String
class com.datadog.android._InternalProxy
Expand Down
1 change: 1 addition & 0 deletions dd-sdk-android-core/api/dd-sdk-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions detekt_custom_safe_calls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -954,16 +954,19 @@ 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()"
- "kotlin.collections.MutableMap.iterator()"
- "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?)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ internal fun DatadogSite.getFlagsEndpoint(customerDomain: String): String? {
* @return Flags host string in format `<customerDomain>.ff-cdn.<site>.<tld>`, 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()))
)
Expand Down Expand Up @@ -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
Expand All @@ -262,7 +269,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest {
attributes = emptyMap()
)
fakeDatadogContext = fakeDatadogContext.copy(
site = DatadogSite.US1_FED
site = site
)

// When
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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? {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -153,6 +157,35 @@ internal class LayoutNodeUtils {
val customAttributes: Map<String, Any?> = 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<Class<*>, MutableMap<String, Method?>> = 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"
Expand All @@ -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<Class<*>, MutableMap<String, Method?>>.resolveMethod(
klass: Class<*>,
methodPrefix: String
): Method? {
val klassCache: MutableMap<String, Method?> = 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
}
}
}
}

This file was deleted.

This file was deleted.

Loading
Loading