diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index da656176ce..df9808e64c 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -9,6 +9,7 @@ data class com.datadog.android.sessionreplay.MapperTypeWrapper, com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper) fun supportsView(android.view.View): Boolean fun getUnsafeMapper(): com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper +fun android.view.View.setSessionReplayHidden(Boolean) object com.datadog.android.sessionreplay.SessionReplay fun enable(SessionReplayConfiguration, com.datadog.android.api.SdkCore = Datadog.getInstance()) fun startRecording(com.datadog.android.api.SdkCore = Datadog.getInstance()) diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index 073ea71c4e..72c7253c09 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -22,6 +22,10 @@ public final class com/datadog/android/sessionreplay/MapperTypeWrapper { public fun toString ()Ljava/lang/String; } +public final class com/datadog/android/sessionreplay/PrivacyOverrideExtensionsKt { + public static final fun setSessionReplayHidden (Landroid/view/View;Z)V +} + public final class com/datadog/android/sessionreplay/SessionReplay { public static final field INSTANCE Lcom/datadog/android/sessionreplay/SessionReplay; public static final fun enable (Lcom/datadog/android/sessionreplay/SessionReplayConfiguration;)V diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensions.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensions.kt new file mode 100644 index 0000000000..f464fec9b4 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensions.kt @@ -0,0 +1,24 @@ +/* + * 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 + +import android.view.View + +/** + * Allows setting a view to be "hidden" in the hierarchy in Session Replay. + * When hidden the view will be replaced with a placeholder in the replay and + * no attempt will be made to record it's children. + * + * @param hide pass `true` to hide the view, or `false` to remove the override + */ +fun View.setSessionReplayHidden(hide: Boolean) { + if (hide) { + this.setTag(R.id.datadog_hidden, true) + } else { + this.setTag(R.id.datadog_hidden, null) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index 522c3fa371..c2e580eaa8 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt @@ -26,6 +26,7 @@ import com.datadog.android.sessionreplay.internal.processor.RecordedDataProcesso import com.datadog.android.sessionreplay.internal.processor.RumContextDataHandler import com.datadog.android.sessionreplay.internal.recorder.callback.OnWindowRefreshedCallback import com.datadog.android.sessionreplay.internal.recorder.mapper.DecorViewMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewWireframeMapper import com.datadog.android.sessionreplay.internal.recorder.resources.BitmapCachesManager import com.datadog.android.sessionreplay.internal.recorder.resources.BitmapPool @@ -173,6 +174,10 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { mappers = mappers, defaultViewMapper = defaultVWM, decorViewMapper = DecorViewMapper(defaultVWM, viewIdentifierResolver), + hiddenViewMapper = HiddenViewMapper( + viewBoundsResolver = viewBoundsResolver, + viewIdentifierResolver = viewIdentifierResolver + ), viewUtilsInternal = ViewUtilsInternal(), internalLogger = internalLogger ), diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt index cd28e7b71c..7071488119 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt @@ -13,7 +13,9 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.measureMethodCallPerf import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.sessionreplay.MapperTypeWrapper +import com.datadog.android.sessionreplay.R import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs +import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.QueueStatusCallback import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext @@ -25,6 +27,7 @@ import com.datadog.android.sessionreplay.utils.NoOpAsyncJobStatusCallback internal class TreeViewTraversal( private val mappers: List>, private val defaultViewMapper: WireframeMapper, + private val hiddenViewMapper: HiddenViewMapper, private val decorViewMapper: WireframeMapper, private val viewUtilsInternal: ViewUtilsInternal, private val internalLogger: InternalLogger @@ -51,7 +54,11 @@ internal class TreeViewTraversal( // try to resolve from the exhaustive type mappers var mapper = findMapperForView(view) - if (mapper != null) { + if (isHidden(view)) { + traversalStrategy = TraversalStrategy.STOP_AND_RETURN_NODE + mapper = hiddenViewMapper + jobStatusCallback = noOpCallback + } else if (mapper != null) { jobStatusCallback = QueueStatusCallback(recordedDataQueueRefs) traversalStrategy = if (mapper is TraverseAllChildrenMapper) { TraversalStrategy.TRAVERSE_ALL_CHILDREN @@ -105,6 +112,9 @@ internal class TreeViewTraversal( return mappers.firstOrNull { it.supportsView(view) }?.getUnsafeMapper() } + private fun isHidden(view: View): Boolean = + view.getTag(R.id.datadog_hidden) == true + data class TraversedTreeView( val mappedWireframes: List, val nextActionStrategy: TraversalStrategy diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt new file mode 100644 index 0000000000..507c1a9f06 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt @@ -0,0 +1,50 @@ +/* + * 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.internal.recorder.mapper + +import android.view.View +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.MappingContext +import com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver + +internal class HiddenViewMapper( + val viewIdentifierResolver: ViewIdentifierResolver, + val viewBoundsResolver: ViewBoundsResolver +) : WireframeMapper { + override fun map( + view: View, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback, + internalLogger: InternalLogger + ): List { + val id = viewIdentifierResolver.resolveChildUniqueIdentifier(view, HIDDEN_KEY_NAME) + ?: return emptyList() + + val density = mappingContext.systemInformation.screenDensity + val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds(view, density) + + return listOf( + MobileSegment.Wireframe.PlaceholderWireframe( + id = id, + x = viewGlobalBounds.x, + y = viewGlobalBounds.y, + width = viewGlobalBounds.width, + height = viewGlobalBounds.height, + label = HIDDEN_VIEW_PLACEHOLDER_TEXT + ) + ) + } + + internal companion object { + internal const val HIDDEN_VIEW_PLACEHOLDER_TEXT = "Hidden" + private const val HIDDEN_KEY_NAME = "hidden" + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml b/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml new file mode 100644 index 0000000000..91ec52bd3f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt new file mode 100644 index 0000000000..2fdc973173 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt @@ -0,0 +1,55 @@ +/* + * 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 + +import android.view.View +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mockito.mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class PrivacyOverrideExtensionsTest { + + @Test + fun `M set tag W setSessionReplayHidden() { hide is true }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayHidden(true) + + // Then + verify(mockView).setTag(eq(R.id.datadog_hidden), eq(true)) + } + + @Test + fun `M set tag to null W setSessionReplayHidden() { hide is false }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayHidden(false) + + // Then + verify(mockView).setTag(eq(R.id.datadog_hidden), isNull()) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt index 0c8a14fcc0..b7ddd804f4 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt @@ -24,6 +24,7 @@ import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.TreeViewTraversal.Companion.METHOD_CALL_MAP_PREFIX import com.datadog.android.sessionreplay.internal.recorder.mapper.DecorViewMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewWireframeMapper import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext @@ -68,6 +69,9 @@ internal class TreeViewTraversalTest { @Mock lateinit var mockDecorViewMapper: DecorViewMapper + @Mock + lateinit var mockHiddenViewMapper: HiddenViewMapper + @Mock lateinit var mockViewUtilsInternal: ViewUtilsInternal @@ -84,6 +88,7 @@ internal class TreeViewTraversalTest { testedTreeViewTraversal = TreeViewTraversal( emptyList(), mockDefaultViewMapper, + mockHiddenViewMapper, mockDecorViewMapper, mockViewUtilsInternal, mockInternalLogger @@ -123,6 +128,7 @@ internal class TreeViewTraversalTest { testedTreeViewTraversal = TreeViewTraversal( fakeTypeMapperWrappers, mockDefaultViewMapper, + mockHiddenViewMapper, mockDecorViewMapper, mockViewUtilsInternal, mockInternalLogger @@ -156,6 +162,7 @@ internal class TreeViewTraversalTest { testedTreeViewTraversal = TreeViewTraversal( emptyList(), mockDefaultViewMapper, + mockHiddenViewMapper, mockDecorViewMapper, mockViewUtilsInternal, mockInternalLogger @@ -190,6 +197,7 @@ internal class TreeViewTraversalTest { testedTreeViewTraversal = TreeViewTraversal( emptyList(), mockDefaultViewMapper, + mockHiddenViewMapper, mockDecorViewMapper, mockViewUtilsInternal, mockInternalLogger @@ -239,6 +247,7 @@ internal class TreeViewTraversalTest { testedTreeViewTraversal = TreeViewTraversal( fakeTypeMapperWrappers, mockDefaultViewMapper, + mockHiddenViewMapper, mockDecorViewMapper, mockViewUtilsInternal, mockInternalLogger @@ -272,6 +281,7 @@ internal class TreeViewTraversalTest { testedTreeViewTraversal = TreeViewTraversal( emptyList(), mockDefaultViewMapper, + mockHiddenViewMapper, mockDecorViewMapper, mockViewUtilsInternal, mockInternalLogger @@ -306,6 +316,7 @@ internal class TreeViewTraversalTest { testedTreeViewTraversal = TreeViewTraversal( emptyList(), mockDefaultViewMapper, + mockHiddenViewMapper, mockDecorViewMapper, mockViewUtilsInternal, mockInternalLogger @@ -442,6 +453,7 @@ internal class TreeViewTraversalTest { testedTreeViewTraversal = TreeViewTraversal( listOf(mockMapper), mockDefaultViewMapper, + mockHiddenViewMapper, mockDecorViewMapper, mockViewUtilsInternal, mockInternalLogger diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapperTest.kt new file mode 100644 index 0000000000..b4dc7e8d76 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapperTest.kt @@ -0,0 +1,116 @@ +/* + * 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.internal.recorder.mapper + +import android.view.View +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper.Companion.HIDDEN_VIEW_PLACEHOLDER_TEXT +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.MappingContext +import com.datadog.android.sessionreplay.recorder.SystemInformation +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.GlobalBounds +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver +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.any +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class HiddenViewMapperTest { + @Mock + lateinit var mockViewIdentifierResolver: ViewIdentifierResolver + + @Mock + lateinit var mockViewBoundsResolver: ViewBoundsResolver + + @Mock + lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + + @Mock + lateinit var mockMappingContext: MappingContext + + @Mock + lateinit var mockSystemInformation: SystemInformation + + @Mock + lateinit var mockGlobalBounds: GlobalBounds + + @Mock + lateinit var mockInternalLogger: InternalLogger + + private lateinit var testedViewMapper: HiddenViewMapper + + @BeforeEach + fun setup() { + whenever(mockViewBoundsResolver.resolveViewGlobalBounds(any(), any())) + .thenReturn(mockGlobalBounds) + + whenever(mockMappingContext.systemInformation) + .thenReturn(mockSystemInformation) + + whenever(mockSystemInformation.screenDensity) + .thenReturn(1f) + + testedViewMapper = HiddenViewMapper( + viewIdentifierResolver = mockViewIdentifierResolver, + viewBoundsResolver = mockViewBoundsResolver + ) + } + + @Test + fun `M return a placeholder with correct label W map()`( + @Mock mockView: View + ) { + // When + val wireframesList = testedViewMapper.map( + view = mockView, + asyncJobStatusCallback = mockAsyncJobStatusCallback, + internalLogger = mockInternalLogger, + mappingContext = mockMappingContext + ) + + // Then + val wireframe = wireframesList[0] + check(wireframe is MobileSegment.Wireframe.PlaceholderWireframe) + assertThat(wireframe.label).isEqualTo(HIDDEN_VIEW_PLACEHOLDER_TEXT) + } + + @Test + fun `M return empty list W map() { failed to resolve unique id }`( + @Mock mockView: View + ) { + // When + whenever(mockViewIdentifierResolver.resolveChildUniqueIdentifier(any(), any())) + .thenReturn(null) + val wireframesList = testedViewMapper.map( + view = mockView, + asyncJobStatusCallback = mockAsyncJobStatusCallback, + internalLogger = mockInternalLogger, + mappingContext = mockMappingContext + ) + + // Then + assertThat(wireframesList).isEmpty() + } +}