From 86d97616b3774a00162f07b78dd9e86600d82901 Mon Sep 17 00:00:00 2001 From: luyi Date: Wed, 6 Nov 2024 10:57:08 +0100 Subject: [PATCH] RUM-6200: Add Tab semantics mapper --- .../semantics/SemanticsWireframeMapper.kt | 1 + .../semantics/TabSemanticsNodeMapper.kt | 63 +++++++ .../compose/internal/utils/SemanticsUtils.kt | 10 ++ .../semantics/TabSemanticsNodeMapperTest.kt | 107 ++++++++++++ .../sample/compose/SampleSelectionScreen.kt | 17 +- .../android/sample/compose/TabsSample.kt | 155 ++++++++++++++++++ 6 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TabSemanticsNodeMapper.kt create mode 100644 features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TabSemanticsNodeMapperTest.kt create mode 100644 sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/TabsSample.kt diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt index 61b6f7e8de..23b270cd58 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt @@ -32,6 +32,7 @@ internal class SemanticsWireframeMapper( private val semanticsUtils: SemanticsUtils = SemanticsUtils(), private val semanticsNodeMapper: Map = mapOf( // TODO RUM-6189 Add Mappers for each Semantics Role + Role.Tab to TabSemanticsNodeMapper(colorStringFormatter, semanticsUtils), Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils), Role.Image to ImageSemanticsNodeMapper(colorStringFormatter) ), diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TabSemanticsNodeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TabSemanticsNodeMapper.kt new file mode 100644 index 0000000000..adb97fe980 --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TabSemanticsNodeMapper.kt @@ -0,0 +1,63 @@ +/* + * 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.internal.mappers.semantics + +import androidx.compose.ui.semantics.SemanticsNode +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.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ColorStringFormatter + +internal class TabSemanticsNodeMapper( + colorStringFormatter: ColorStringFormatter, + private val semanticsUtils: SemanticsUtils = SemanticsUtils() +) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) { + + override fun map( + semanticsNode: SemanticsNode, + parentContext: UiContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): SemanticsWireframe { + val parentFrames = resolveParentColor(semanticsNode) + return SemanticsWireframe( + wireframes = parentFrames, + uiContext = parentContext + ) + } + + private fun resolveParentColor(semanticsNode: SemanticsNode): List { + val globalBounds = resolveBounds(semanticsNode) + + // TODO RUM-7082: Consider use `UiContext` to pass the color information + var parentColor = semanticsNode.parent?.let { parent -> + semanticsUtils.resolveBackgroundColor(parent)?.let { + convertColor(it) + } + } + + // If parent color is not specified, it may be in the grandparent modifier info. + if (parentColor == null) { + parentColor = semanticsNode.parent?.parent?.let { grandParentNode -> + semanticsUtils.resolveBackgroundColor(grandParentNode)?.let { + convertColor(it) + } + } + } + + val shapeStyle = MobileSegment.ShapeStyle(backgroundColor = parentColor) + return MobileSegment.Wireframe.ShapeWireframe( + id = semanticsNode.id.toLong(), + x = globalBounds.x, + y = globalBounds.y, + width = globalBounds.width, + height = globalBounds.height, + shapeStyle = shapeStyle + ).let { listOf(it) } + } +} 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 2d0ddf0ce3..a0607a8ff3 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 @@ -89,6 +89,16 @@ internal class SemanticsUtils { return backgroundInfoList } + internal fun resolveBackgroundColor(semanticsNode: SemanticsNode): Long? { + val backgroundModifierInfo = + semanticsNode.layoutInfo.getModifierInfo().firstOrNull { modifierInfo -> + ComposeReflection.BackgroundElementClass?.isInstance(modifierInfo.modifier) == true + } + return backgroundModifierInfo?.let { + ComposeReflection.ColorField?.getSafe(it.modifier) as? Long + } + } + internal fun resolveBackgroundInfoId(backgroundInfo: BackgroundInfo): Long { return System.identityHashCode(backgroundInfo).toLong() } diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TabSemanticsNodeMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TabSemanticsNodeMapperTest.kt new file mode 100644 index 0000000000..e51b5d1676 --- /dev/null +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/TabSemanticsNodeMapperTest.kt @@ -0,0 +1,107 @@ +/* + * 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.internal.mappers.semantics + +import androidx.compose.ui.semantics.SemanticsNode +import com.datadog.android.sessionreplay.compose.internal.data.UiContext +import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(SessionReplayComposeForgeConfigurator::class) +internal class TabSemanticsNodeMapperTest : AbstractCompositionGroupMapperTest() { + + private lateinit var testedTabSemanticsNodeMapper: TabSemanticsNodeMapper + + @Mock + private lateinit var mockSemanticsNode: SemanticsNode + + @Mock + private lateinit var mockParentNode: SemanticsNode + + @Mock + private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + + @LongForgery(min = 0xffffffff) + var fakeBackgroundColor: Long = 0L + + @StringForgery(regex = "#[0-9A-F]{8}") + lateinit var fakeBackgroundColorHexString: String + + @Forgery + lateinit var fakeUiContext: UiContext + + @BeforeEach + override fun `set up`(forge: Forge) { + super.`set up`(forge) + mockColorStringFormatter(fakeBackgroundColor, fakeBackgroundColorHexString) + + testedTabSemanticsNodeMapper = TabSemanticsNodeMapper( + colorStringFormatter = mockColorStringFormatter, + semanticsUtils = mockSemanticsUtils + ) + } + + @Test + fun `M return the correct wireframe W map`() { + // Given + val mockSemanticsNode = mockSemanticsNode() + whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn rectToBounds( + fakeBounds, + fakeDensity + ) + whenever(mockSemanticsUtils.resolveBackgroundColor(mockParentNode)) doReturn fakeBackgroundColor + whenever(mockSemanticsNode.parent).doReturn(mockParentNode) + // When + val actual = testedTabSemanticsNodeMapper.map( + mockSemanticsNode, + fakeUiContext, + mockAsyncJobStatusCallback + ) + + // Then + val expected = MobileSegment.Wireframe.ShapeWireframe( + id = fakeSemanticsId.toLong(), + x = (fakeBounds.left / fakeDensity).toLong(), + y = (fakeBounds.top / fakeDensity).toLong(), + width = (fakeBounds.size.width / fakeDensity).toLong(), + height = (fakeBounds.size.height / fakeDensity).toLong(), + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = fakeBackgroundColorHexString + ) + ) + assertThat(actual.wireframes).contains(expected) + } + + private fun mockSemanticsNode(): SemanticsNode { + return mockSemanticsNodeWithBound { + whenever(mockSemanticsNode.layoutInfo).doReturn(mockLayoutInfo) + } + } +} diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt index aa82f8b516..b611b4af0b 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt @@ -26,7 +26,8 @@ import androidx.navigation.compose.composable internal fun SampleSelectionScreen( onTypographyClicked: () -> Unit, onLegacyClicked: () -> Unit, - onImageClicked: () -> Unit + onImageClicked: () -> Unit, + onTabsClicked: () -> Unit ) { Column( modifier = Modifier.fillMaxSize(), @@ -45,6 +46,10 @@ internal fun SampleSelectionScreen( text = "Image Sample", onClick = onImageClicked ) + StyledButton( + text = "Tabs Sample", + onClick = onTabsClicked + ) StyledButton( text = "Legacy Sample", onClick = onLegacyClicked @@ -76,6 +81,9 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle onImageClicked = { navController.navigate(SampleScreen.Image.navigationRoute) }, + onTabsClicked = { + navController.navigate(SampleScreen.Tabs.navigationRoute) + }, onLegacyClicked = { navController.navigate(SampleScreen.Legacy.navigationRoute) } @@ -90,6 +98,10 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle ImageSample() } + composable(SampleScreen.Tabs.navigationRoute) { + TabsSample() + } + activity(SampleScreen.Legacy.navigationRoute) { activityClass = LegacyComposeActivity::class } @@ -102,6 +114,7 @@ internal sealed class SampleScreen( object Root : SampleScreen(COMPOSE_ROOT) object Typography : SampleScreen("$COMPOSE_ROOT/typography") object Image : SampleScreen("$COMPOSE_ROOT/image") + object Tabs : SampleScreen("$COMPOSE_ROOT/tabs") object Legacy : SampleScreen("$COMPOSE_ROOT/legacy") companion object { @@ -119,6 +132,8 @@ private fun PreviewSampleSelectionScreen() { onImageClicked = { }, onTypographyClicked = { + }, + onTabsClicked = { } ) } diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/TabsSample.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/TabsSample.kt new file mode 100644 index 0000000000..32a61d9a21 --- /dev/null +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/TabsSample.kt @@ -0,0 +1,155 @@ +/* + * 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.sample.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.BottomNavigation +import androidx.compose.material.BottomNavigationItem +import androidx.compose.material.Icon +import androidx.compose.material.Scaffold +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.TabRowDefaults +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Email +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.datadog.android.rum.GlobalRumMonitor +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.pagerTabIndicatorOffset +import com.google.accompanist.pager.rememberPagerState +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch + +@Composable +internal fun TabsSample() { + Scaffold( + bottomBar = { + NavigationBar() + } + ) { + AppContent(modifier = Modifier.padding(it)) + } +} + +@Composable +@OptIn(ExperimentalPagerApi::class) +@Suppress("LongMethod") +private fun AppContent(modifier: Modifier = Modifier) { + Column(modifier) { + val pages = remember { + listOf(Page.Navigation, Page.Interactions) + } + val pagerState = rememberPagerState() + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + val rumMonitor = GlobalRumMonitor.get() + val screen = pages[pagerState.currentPage].trackingName + if (event == Lifecycle.Event.ON_RESUME) { + rumMonitor.startView(screen, screen) + } else if (event == Lifecycle.Event.ON_PAUSE) { + rumMonitor.stopView(screen) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + // drop 1st, because it will be tracked by the lifecycle + .drop(1) + .collect { page -> + val screen = pages[page].trackingName + GlobalRumMonitor.get().startView(screen, screen) + } + } + + TabRow( + selectedTabIndex = pagerState.currentPage, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + modifier = Modifier.pagerTabIndicatorOffset( + pagerState, + tabPositions + ), + height = TabRowDefaults.IndicatorHeight * 2 + ) + } + ) { + val coroutineScope = rememberCoroutineScope() + pages.forEachIndexed { index, page -> + Tab( + text = { Text(page.name) }, + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + } + ) + } + } + HorizontalPager( + count = pages.size, + state = pagerState + ) { page -> + when (page) { + 0 -> NavigationSampleView() + else -> InteractionSampleView() + } + } + } +} + +@Composable +private fun NavigationBar() { + val selectedIndex = remember { mutableIntStateOf(1) } + BottomNavigation { + BottomNavigationItem( + selected = selectedIndex.intValue == 1, + onClick = { + selectedIndex.intValue = 1 + }, + icon = { + Icon(imageVector = Icons.Filled.Edit, contentDescription = "edit") + }, + label = { + Text("label 1") + } + ) + + BottomNavigationItem( + selected = selectedIndex.intValue == 2, + onClick = { + selectedIndex.intValue = 2 + }, + icon = { + Icon(imageVector = Icons.Filled.Email, contentDescription = "mail") + }, + label = { + Text("label 2") + } + ) + } +}