From 1222834450ec05d87aa03305b99adbd84cb535ab Mon Sep 17 00:00:00 2001 From: luyi Date: Mon, 21 Oct 2024 17:53:59 +0200 Subject: [PATCH] Update the resolveBounds logic for semantics node --- detekt_custom.yml | 1 + .../semantics/AbstractSemanticsNodeMapper.kt | 12 +- .../semantics/ButtonSemanticsNodeMapper.kt | 2 +- .../internal/reflection/ComposeReflection.kt | 109 +++++++++++------- .../compose/internal/utils/SemanticsUtils.kt | 22 ++++ .../AbstractSemanticsNodeMapperTest.kt | 22 ++-- .../ButtonSemanticsNodeMapperTest.kt | 18 ++- .../test/elmyr/GlobalBoundsForgeryFactory.kt | 22 ++++ .../SessionReplayComposeForgeConfigurator.kt | 1 + 9 files changed, 145 insertions(+), 64 deletions(-) create mode 100644 features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/GlobalBoundsForgeryFactory.kt diff --git a/detekt_custom.yml b/detekt_custom.yml index 22afb0382a..49358b91ec 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -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" diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt index 41f03c99f4..d4ea351eb2 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapper.kt @@ -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? { diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt index 7f915e5244..1d092ae43f 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapper.kt @@ -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, diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt index 77efe4a434..1e8f274302 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/reflection/ComposeReflection.kt @@ -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") @@ -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") @@ -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 { @@ -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( @@ -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 } } @@ -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" diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt index 98f5dbc737..172e77bda0 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt @@ -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 @@ -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, diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapperTest.kt index d7b294d34f..68437d6487 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AbstractSemanticsNodeMapperTest.kt @@ -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 @@ -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 @@ -66,6 +67,9 @@ internal open class AbstractCompositionGroupMapperTest { @Mock lateinit var mockDensity: Density + @Mock + lateinit var mockSemanticsUtils: SemanticsUtils + @FloatForgery var fakeDensity = 0f @@ -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() + 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 { @@ -128,8 +129,9 @@ internal open class AbstractCompositionGroupMapperTest { } internal class StubAbstractSemanticsNodeMapper( + semanticsUtils: SemanticsUtils, colorStringFormatter: ColorStringFormatter -) : AbstractSemanticsNodeMapper(colorStringFormatter) { +) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) { var mappedWireframe: ComposeWireframe? = null diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt index aab9e31eca..dbe98b2b77 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/ButtonSemanticsNodeMapperTest.kt @@ -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 @@ -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 @@ -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() ) @@ -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) + } } diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/GlobalBoundsForgeryFactory.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/GlobalBoundsForgeryFactory.kt new file mode 100644 index 0000000000..4d94b4205c --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/GlobalBoundsForgeryFactory.kt @@ -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 { + 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) + ) + } +} diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/SessionReplayComposeForgeConfigurator.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/SessionReplayComposeForgeConfigurator.kt index a678e55701..9c44c18637 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/SessionReplayComposeForgeConfigurator.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/SessionReplayComposeForgeConfigurator.kt @@ -33,5 +33,6 @@ class SessionReplayComposeForgeConfigurator : BaseConfigurator() { forge.addFactory(ComposeWireframeForgeryFactory()) forge.addFactory(MappingContextForgeryFactory()) forge.addFactory(SystemInformationForgeryFactory()) + forge.addFactory(GlobalBoundsForgeryFactory()) } }