Skip to content

Commit

Permalink
Update the resolveBounds logic for semantics node
Browse files Browse the repository at this point in the history
  • Loading branch information
ambushwork committed Oct 22, 2024
1 parent 6f3c5f3 commit 1222834
Show file tree
Hide file tree
Showing 9 changed files with 145 additions and 64 deletions.
1 change: 1 addition & 0 deletions detekt_custom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ datadog:
- "java.lang.Character.toChars(kotlin.Int):java.lang.IllegalArgumentException"
- "java.lang.Class.forName(kotlin.String?):java.lang.LinkageError,java.lang.ExceptionInInitializerError,java.lang.ClassNotFoundException"
- "java.lang.Class.getDeclaredField(kotlin.String?):java.lang.NoSuchFieldException,java.lang.SecurityException,java.lang.NullPointerException"
- "java.lang.Class.getDeclaredMethod(kotlin.String?, kotlin.Array?):java.lang.NoSuchMethodException,java.lang.SecurityException,java.lang.NullPointerException"
- "java.lang.Class.getMethod(kotlin.String?, kotlin.Array?):java.lang.NoSuchMethodException,java.lang.SecurityException,java.lang.NullPointerException"
- "java.lang.Class.isAssignableFrom(java.lang.Class?):java.lang.NullPointerException"
- "java.lang.Runtime.addShutdownHook(java.lang.Thread):java.lang.IllegalArgumentException,java.lang.IllegalStateException,java.lang.SecurityException"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,18 @@ package com.datadog.android.sessionreplay.compose.internal.mappers.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.semantics.SemanticsNode
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
import com.datadog.android.sessionreplay.utils.GlobalBounds
import kotlin.math.roundToInt

internal abstract class AbstractSemanticsNodeMapper(
private val colorStringFormatter: ColorStringFormatter
private val colorStringFormatter: ColorStringFormatter,
private val semanticsUtils: SemanticsUtils = SemanticsUtils()
) : SemanticsNodeMapper {

protected fun resolveBounds(semanticsNode: SemanticsNode): GlobalBounds {
val rect = semanticsNode.boundsInRoot
val density = semanticsNode.layoutInfo.density.density
val width = ((rect.right - rect.left) / density).toLong()
val height = ((rect.bottom - rect.top) / density).toLong()
val x = (rect.left / density).toLong()
val y = (rect.top / density).toLong()
return GlobalBounds(x, y, width, height)
return semanticsUtils.resolveInnerBounds(semanticsNode)
}

protected fun convertColor(color: Long): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import com.datadog.android.sessionreplay.utils.GlobalBounds
internal class ButtonSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
private val semanticsUtils: SemanticsUtils = SemanticsUtils()
) : AbstractSemanticsNodeMapper(colorStringFormatter) {
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {

override fun map(
semanticsNode: SemanticsNode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.datadog.android.Datadog
import com.datadog.android.api.InternalLogger
import com.datadog.android.api.feature.FeatureSdkCore
import java.lang.reflect.Field
import java.lang.reflect.Method

internal object ComposeReflection {
val WrappedCompositionClass = getClassSafe("androidx.compose.ui.platform.WrappedComposition")
Expand All @@ -36,7 +37,9 @@ internal object ComposeReflection {
val OwnerField = WrappedCompositionClass?.getDeclaredFieldSafe("owner")

val LayoutNodeClass = getClassSafe("androidx.compose.ui.node.LayoutNode")
val LayoutNodeOwnerField = LayoutNodeClass?.getDeclaredFieldSafe("owner")

val GetInnerLayerCoordinatorMethod = LayoutNodeClass?.getDeclaredMethodSafe("getInnerLayerCoordinator")

val AndroidComposeViewClass = getClassSafe("androidx.compose.ui.platform.AndroidComposeView")
val SemanticsOwner = AndroidComposeViewClass?.getDeclaredFieldSafe("semanticsOwner")

Expand Down Expand Up @@ -77,6 +80,11 @@ internal fun Field.accessible(): Field {
return this
}

internal fun Method.accessible(): Method {
isAccessible = true
return this
}

@Suppress("TooGenericExceptionCaught")
internal fun Field.getSafe(target: Any?): Any? {
return try {
Expand All @@ -98,12 +106,7 @@ internal fun Field.getSafe(target: Any?): Any? {
)
null
} catch (e: NullPointerException) {
(Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{ "Unable to get field $name through reflection, target is null" },
e
)
logNullPointerException(name, LOG_TYPE_FIELD, e)
null
} catch (e: ExceptionInInitializerError) {
(Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log(
Expand Down Expand Up @@ -136,15 +139,7 @@ internal fun getClassSafe(className: String): Class<*>? {
)
null
} catch (e: ClassNotFoundException) {
(Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{
"Unable to get class $className through reflection, " +
"either because of obfuscation or dependency version mismatch"
},
e
)
logNoSuchException(className, LOG_TYPE_CLASS, e)
null
}
}
Expand All @@ -154,35 +149,67 @@ internal fun Class<*>.getDeclaredFieldSafe(fieldName: String): Field? {
return try {
getDeclaredField(fieldName).accessible()
} catch (e: SecurityException) {
(Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{
"Unable to get field $fieldName through reflection"
},
e
)
logSecurityException(fieldName, LOG_TYPE_FIELD, e)
null
} catch (e: NullPointerException) {
(Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{
"Unable to get field $fieldName through reflection, name is null"
},
e
)
logNullPointerException(fieldName, LOG_TYPE_FIELD, e)
null
} catch (e: NoSuchFieldException) {
(Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{
"Unable to get field $fieldName through reflection, " +
"either because of obfuscation or dependency version mismatch"
},
e
)
logNoSuchException(fieldName, LOG_TYPE_FIELD, e)
null
}
}

@Suppress("TooGenericExceptionCaught")
internal fun Class<*>.getDeclaredMethodSafe(methodName: String): Method? {
return try {
getDeclaredMethod(methodName).accessible()
} catch (e: SecurityException) {
logSecurityException(methodName, LOG_TYPE_METHOD, e)
null
} catch (e: NullPointerException) {
logNullPointerException(methodName, LOG_TYPE_METHOD, e)
null
} catch (e: NoSuchMethodException) {
logNoSuchException(methodName, LOG_TYPE_METHOD, e)
null
}
}

private fun logSecurityException(name: String, type: String, e: SecurityException) {
(Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{
"Unable to get $type $name through reflection"
},
e
)
}

private fun logNullPointerException(name: String, type: String, e: NullPointerException) {
(Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{
"Unable to get $type $name through reflection, name is null"
},
e
)
}

private fun logNoSuchException(name: String, type: String, e: ReflectiveOperationException) {
(Datadog.getInstance() as? FeatureSdkCore)?.internalLogger?.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{
"Unable to get $type $name through reflection, " +
"either because of obfuscation or dependency version mismatch"
},
e
)
}

private const val LOG_TYPE_METHOD = "method"
private const val LOG_TYPE_FIELD = "field"
private const val LOG_TYPE_CLASS = "field"
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composition
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.unit.Density
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.CompositionField
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.GetInnerLayerCoordinatorMethod
import com.datadog.android.sessionreplay.compose.internal.reflection.getSafe
import com.datadog.android.sessionreplay.utils.GlobalBounds

Expand All @@ -42,6 +44,26 @@ internal class SemanticsUtils {
return ComposeReflection.ColorField?.getSafe(modifier) as? Long
}

internal fun resolveInnerBounds(semanticsNode: SemanticsNode): GlobalBounds {
val offset = semanticsNode.positionInRoot
// Resolve the measured size.
val size = resolveInnerSize(semanticsNode)
val density = semanticsNode.layoutInfo.density.density
val width = (size.width / density).toLong()
val height = (size.height / density).toLong()
val x = (offset.x / density).toLong()
val y = (offset.y / density).toLong()
return GlobalBounds(x, y, width, height)
}

private fun resolveInnerSize(semanticsNode: SemanticsNode): Size {
val innerLayerCoordinator = GetInnerLayerCoordinatorMethod?.invoke(semanticsNode.layoutInfo)
val placeable = innerLayerCoordinator as? Placeable
val height = placeable?.height ?: 0
val width = placeable?.width ?: 0
return Size(width = width.toFloat(), height = height.toFloat())
}

internal fun resolveSemanticsModifierCornerRadius(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.Density
import com.datadog.android.sessionreplay.compose.internal.data.ComposeWireframe
import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
Expand Down Expand Up @@ -50,7 +51,7 @@ internal open class AbstractCompositionGroupMapperTest {
private lateinit var testedMapper: StubAbstractSemanticsNodeMapper

@Forgery
private lateinit var fakeWireframe: ComposeWireframe
lateinit var fakeGlobalBounds: GlobalBounds

@IntForgery
var fakeSemanticsId: Int = 0
Expand All @@ -66,6 +67,9 @@ internal open class AbstractCompositionGroupMapperTest {
@Mock
lateinit var mockDensity: Density

@Mock
lateinit var mockSemanticsUtils: SemanticsUtils

@FloatForgery
var fakeDensity = 0f

Expand All @@ -77,23 +81,20 @@ internal open class AbstractCompositionGroupMapperTest {
right = forge.aFloat(),
bottom = forge.aFloat()
)
testedMapper = StubAbstractSemanticsNodeMapper(mockColorStringFormatter)
testedMapper = StubAbstractSemanticsNodeMapper(mockSemanticsUtils, mockColorStringFormatter)
}

@Test
fun `M return correct bound W resolveBounds`() {
// Given
testedMapper.mappedWireframe = fakeWireframe
val mockNode = mockSemanticsNodeWithBound()
val semanticsNode = mock<SemanticsNode>()
whenever(mockSemanticsUtils.resolveInnerBounds(semanticsNode)) doReturn fakeGlobalBounds

// When
val result = testedMapper.stubResolveBounds(mockNode)
val result = testedMapper.stubResolveBounds(semanticsNode)

// Then
assertThat(result.x).isEqualTo((fakeBounds.left / fakeDensity).toLong())
assertThat(result.y).isEqualTo((fakeBounds.top / fakeDensity).toLong())
assertThat(result.height).isEqualTo((fakeBounds.size.height / fakeDensity).toLong())
assertThat(result.width).isEqualTo((fakeBounds.size.width / fakeDensity).toLong())
assertThat(result).isEqualTo(fakeGlobalBounds)
}

protected fun mockSemanticsNodeWithBound(additionalMock: SemanticsNode.() -> Unit = {}): SemanticsNode {
Expand Down Expand Up @@ -128,8 +129,9 @@ internal open class AbstractCompositionGroupMapperTest {
}

internal class StubAbstractSemanticsNodeMapper(
semanticsUtils: SemanticsUtils,
colorStringFormatter: ColorStringFormatter
) : AbstractSemanticsNodeMapper(colorStringFormatter) {
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {

var mappedWireframe: ComposeWireframe? = null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@

package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.SemanticsNode
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.GlobalBounds
import fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.annotation.FloatForgery
import fr.xgouchet.elmyr.annotation.Forgery
Expand Down Expand Up @@ -47,9 +48,6 @@ internal class ButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTes
@Mock
private lateinit var mockSemanticsNode: SemanticsNode

@Mock
private lateinit var mockSemanticsUtils: SemanticsUtils

@Mock
private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback

Expand Down Expand Up @@ -86,6 +84,10 @@ internal class ButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTes
fun `M return the correct wireframe W map`() {
// Given
val mockSemanticsNode = mockSemanticsNode()
whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn rectToBounds(
fakeBounds,
fakeDensity
)
whenever(mockSemanticsUtils.resolveSemanticsModifierColor(mockSemanticsNode)).thenReturn(
Color(fakeBackgroundColor).value.toLong()
)
Expand Down Expand Up @@ -118,4 +120,12 @@ internal class ButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTes
)
assertThat(actual.wireframes).contains(expected)
}

private fun rectToBounds(rect: Rect, density: Float): GlobalBounds {
val width = ((rect.right - rect.left) / density).toLong()
val height = ((rect.bottom - rect.top) / density).toLong()
val x = (rect.left / density).toLong()
val y = (rect.top / density).toLong()
return GlobalBounds(x, y, width, height)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* 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.sessionreplay.compose.test.elmyr

import com.datadog.android.sessionreplay.utils.GlobalBounds
import fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.ForgeryFactory

internal class GlobalBoundsForgeryFactory : ForgeryFactory<GlobalBounds> {
override fun getForgery(forge: Forge): GlobalBounds {
return GlobalBounds(
x = forge.aLong(min = 128L, max = 65536L),
y = forge.aLong(min = 128L, max = 65536L),
width = forge.aLong(min = 32L, max = 65536L),
height = forge.aLong(min = 32L, max = 65536L)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ class SessionReplayComposeForgeConfigurator : BaseConfigurator() {
forge.addFactory(ComposeWireframeForgeryFactory())
forge.addFactory(MappingContextForgeryFactory())
forge.addFactory(SystemInformationForgeryFactory())
forge.addFactory(GlobalBoundsForgeryFactory())
}
}

0 comments on commit 1222834

Please sign in to comment.