From 9124b62682e42548f3923710af38c72eb71380fc Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Tue, 8 Oct 2024 11:08:14 +0300 Subject: [PATCH] RUM-6219: Add image and text_and_input overrides --- .../api/apiSurface | 2 + .../api/dd-sdk-android-session-replay.api | 2 + .../PrivacyOverrideExtensions.kt | 28 +++++ .../recorder/SessionReplayRecorder.kt | 3 +- .../internal/recorder/SnapshotProducer.kt | 56 ++++++++- .../src/main/res/values/ids.xml | 2 + .../PrivacyOverrideExtensionsTest.kt | 55 +++++++++ .../internal/recorder/SnapshotProducerTest.kt | 108 +++++++++++++++++- 8 files changed, 251 insertions(+), 5 deletions(-) diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index df9808e64c..6d8826ca40 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -10,6 +10,8 @@ data class com.datadog.android.sessionreplay.MapperTypeWrapper fun android.view.View.setSessionReplayHidden(Boolean) +fun android.view.View.setSessionReplayImagePrivacy(ImagePrivacy?) +fun android.view.View.setSessionReplayTextAndInputPrivacy(TextAndInputPrivacy?) 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 72c7253c09..40ec44e26e 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 @@ -24,6 +24,8 @@ public final class com/datadog/android/sessionreplay/MapperTypeWrapper { public final class com/datadog/android/sessionreplay/PrivacyOverrideExtensionsKt { public static final fun setSessionReplayHidden (Landroid/view/View;Z)V + public static final fun setSessionReplayImagePrivacy (Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;)V + public static final fun setSessionReplayTextAndInputPrivacy (Landroid/view/View;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;)V } public final class com/datadog/android/sessionreplay/SessionReplay { 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 index f464fec9b4..312b86c120 100644 --- 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 @@ -22,3 +22,31 @@ fun View.setSessionReplayHidden(hide: Boolean) { this.setTag(R.id.datadog_hidden, null) } } + +/** + * Allows overriding the image privacy for a view in Session Replay. + * + * @param privacy the new privacy level to use for the view + * or null to remove the override. + */ +fun View.setSessionReplayImagePrivacy(privacy: ImagePrivacy?) { + if (privacy == null) { + this.setTag(R.id.datadog_image_privacy, null) + } else { + this.setTag(R.id.datadog_image_privacy, privacy.toString()) + } +} + +/** + * Allows overriding the text and input privacy for a view in Session Replay. + * + * @param privacy the new privacy level to use for the view + * or null to remove the override. + */ +fun View.setSessionReplayTextAndInputPrivacy(privacy: TextAndInputPrivacy?) { + if (privacy == null) { + this.setTag(R.id.datadog_text_and_input_privacy, null) + } else { + this.setTag(R.id.datadog_text_and_input_privacy, privacy.toString()) + } +} 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 c2e580eaa8..a3f7ecd630 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 @@ -183,7 +183,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { ), ComposedOptionSelectorDetector( customOptionSelectorDetectors + DefaultOptionSelectorDetector() - ) + ), + internalLogger = internalLogger ), recordedDataQueueHandler = recordedDataQueueHandler, sdkCore = sdkCore, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt index 65b52475f0..9aa581a492 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt @@ -9,7 +9,9 @@ package com.datadog.android.sessionreplay.internal.recorder import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.R import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.model.MobileSegment @@ -22,7 +24,8 @@ import java.util.LinkedList internal class SnapshotProducer( private val imageWireframeHelper: ImageWireframeHelper, private val treeViewTraversal: TreeViewTraversal, - private val optionSelectorDetector: OptionSelectorDetector + private val optionSelectorDetector: OptionSelectorDetector, + private val internalLogger: InternalLogger ) { @UiThread @@ -55,7 +58,8 @@ internal class SnapshotProducer( recordedDataQueueRefs: RecordedDataQueueRefs ): Node? { return withinSRBenchmarkSpan(view::class.java.simpleName, view is ViewGroup) { - val traversedTreeView = treeViewTraversal.traverse(view, mappingContext, recordedDataQueueRefs) + val localMappingContext = resolvePrivacyOverrides(view, mappingContext) + val traversedTreeView = treeViewTraversal.traverse(view, localMappingContext, recordedDataQueueRefs) val nextTraversalStrategy = traversedTreeView.nextActionStrategy val resolvedWireframes = traversedTreeView.mappedWireframes if (nextTraversalStrategy == TraversalStrategy.STOP_AND_DROP_NODE) { @@ -70,7 +74,7 @@ internal class SnapshotProducer( view.childCount > 0 && nextTraversalStrategy == TraversalStrategy.TRAVERSE_ALL_CHILDREN ) { - val childMappingContext = resolveChildMappingContext(view, mappingContext) + val childMappingContext = resolveChildMappingContext(view, localMappingContext) val parentsCopy = LinkedList(parents).apply { addAll(resolvedWireframes) } for (i in 0 until view.childCount) { val viewChild = view.getChildAt(i) ?: continue @@ -97,4 +101,50 @@ internal class SnapshotProducer( parentMappingContext } } + + private fun resolvePrivacyOverrides(view: View, mappingContext: MappingContext): MappingContext { + val imagePrivacy = + try { + val privacy = view.getTag(R.id.datadog_image_privacy) as? String + if (privacy == null) { + mappingContext.imagePrivacy + } else { + ImagePrivacy.valueOf(privacy) + } + } catch (e: IllegalArgumentException) { + logInvalidPrivacyLevelError(e) + mappingContext.imagePrivacy + } + + val textAndInputPrivacy = + try { + val privacy = view.getTag(R.id.datadog_text_and_input_privacy) as? String + if (privacy == null) { + mappingContext.textAndInputPrivacy + } else { + TextAndInputPrivacy.valueOf(privacy) + } + } catch (e: IllegalArgumentException) { + logInvalidPrivacyLevelError(e) + mappingContext.textAndInputPrivacy + } + + return mappingContext.copy( + imagePrivacy = imagePrivacy, + textAndInputPrivacy = textAndInputPrivacy + ) + } + + private fun logInvalidPrivacyLevelError(e: Exception) { + internalLogger.log( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), + { INVALID_PRIVACY_LEVEL_ERROR }, + e + ) + } + + internal companion object { + internal const val INVALID_PRIVACY_LEVEL_ERROR = "Invalid privacy level" + } } 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 index 91ec52bd3f..71501d03f6 100644 --- 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 @@ -6,4 +6,6 @@ + + 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 index 2fdc973173..dc6b073523 100644 --- 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 @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay import android.view.View import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.Test @@ -52,4 +53,58 @@ internal class PrivacyOverrideExtensionsTest { // Then verify(mockView).setTag(eq(R.id.datadog_hidden), isNull()) } + + @Test + fun `M set tag W setSessionReplayImagePrivacy() { with privacy }`( + forge: Forge + ) { + // Given + val mockView = mock() + val mockPrivacy = forge.aValueFrom(ImagePrivacy::class.java) + + // When + mockView.setSessionReplayImagePrivacy(mockPrivacy) + + // Then + verify(mockView).setTag(eq(R.id.datadog_image_privacy), eq(mockPrivacy.toString())) + } + + @Test + fun `M set tag to null W setSessionReplayImagePrivacy() { privacy is null }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayImagePrivacy(null) + + // Then + verify(mockView).setTag(eq(R.id.datadog_image_privacy), isNull()) + } + + @Test + fun `M set tag W setSessionReplayTextAndInputPrivacy() { with privacy }`( + forge: Forge + ) { + // Given + val mockView = mock() + val mockPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java) + + // When + mockView.setSessionReplayTextAndInputPrivacy(mockPrivacy) + + // Then + verify(mockView).setTag(eq(R.id.datadog_text_and_input_privacy), eq(mockPrivacy.toString())) + } + + @Test + fun `M set tag to null W setSessionReplayTextAndInputPrivacy() { privacy is null }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayTextAndInputPrivacy(null) + + // Then + verify(mockView).setTag(eq(R.id.datadog_text_and_input_privacy), isNull()) + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt index e12aa6096a..47427de3e4 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt @@ -8,13 +8,18 @@ package com.datadog.android.sessionreplay.internal.recorder import android.view.View import android.view.ViewGroup +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.R import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs +import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer.Companion.INVALID_PRIVACY_LEVEL_ERROR 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.setSessionReplayImagePrivacy +import com.datadog.android.sessionreplay.setSessionReplayTextAndInputPrivacy import com.datadog.android.sessionreplay.utils.ImageWireframeHelper import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery @@ -60,6 +65,9 @@ internal class SnapshotProducerTest { @Mock lateinit var mockImageWireframeHelper: ImageWireframeHelper + @Mock + lateinit var mockInternalLogger: InternalLogger + @Forgery lateinit var fakeSystemInformation: SystemInformation @@ -77,7 +85,8 @@ internal class SnapshotProducerTest { testedSnapshotProducer = SnapshotProducer( mockImageWireframeHelper, mockTreeViewTraversal, - mockOptionSelectorDetector + mockOptionSelectorDetector, + mockInternalLogger ) } @@ -366,6 +375,103 @@ internal class SnapshotProducerTest { assertThat(snapshot).isEqualTo(expectedSnapshot) } + @Test + fun `M apply override privacy to parent and children W produce()`( + forge: Forge + ) { + // Given + val mockChildren: List = forge.aList { mock() } + val mockRoot: ViewGroup = mock { root -> + whenever(root.childCount).thenReturn(mockChildren.size) + whenever(root.getChildAt(any())).thenAnswer { mockChildren[it.getArgument(0)] } + } + val fakeImagePrivacy = forge.aValueFrom(ImagePrivacy::class.java) + val fakeTextAndInputPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java) + mockRoot.setSessionReplayImagePrivacy(fakeImagePrivacy) + mockRoot.setSessionReplayTextAndInputPrivacy(fakeTextAndInputPrivacy) + val fakeTraversedTreeView = TreeViewTraversal.TraversedTreeView( + fakeViewWireframes, + TraversalStrategy.TRAVERSE_ALL_CHILDREN + ) + whenever(mockTreeViewTraversal.traverse(any(), any(), any())) + .thenReturn(fakeTraversedTreeView) + .thenReturn( + fakeTraversedTreeView.copy( + nextActionStrategy = + TraversalStrategy.STOP_AND_DROP_NODE + ) + ) + + // When + testedSnapshotProducer.produce( + mockRoot, + fakeSystemInformation, + fakeTextAndInputPrivacy, + fakeImagePrivacy, + mockRecordedDataQueueRefs + ) + + // Then + val argumentCaptor = argumentCaptor() + verify(mockTreeViewTraversal, times(1 + mockChildren.size)) + .traverse(any(), argumentCaptor.capture(), any()) + argumentCaptor.allValues.forEach { + assertThat(it.imagePrivacy).isEqualTo(fakeImagePrivacy) + assertThat(it.textAndInputPrivacy).isEqualTo(fakeTextAndInputPrivacy) + } + } + + @Test + fun `M log invalid privacy level W produce() { invalid override tag value }`( + forge: Forge + ) { + // Given + val mockChildren: List = forge.aList { mock() } + val mockRoot: ViewGroup = mock { root -> + whenever(root.childCount).thenReturn(mockChildren.size) + whenever(root.getChildAt(any())).thenAnswer { mockChildren[it.getArgument(0)] } + whenever(root.getTag(R.id.datadog_image_privacy)).thenReturn("arglblargl") + whenever(root.getTag(R.id.datadog_text_and_input_privacy)).thenReturn("arglblargl") + } + val fakeImagePrivacy = forge.aValueFrom(ImagePrivacy::class.java) + val fakeTextAndInputPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java) + + val fakeTraversedTreeView = TreeViewTraversal.TraversedTreeView( + fakeViewWireframes, + TraversalStrategy.TRAVERSE_ALL_CHILDREN + ) + whenever(mockTreeViewTraversal.traverse(any(), any(), any())) + .thenReturn(fakeTraversedTreeView) + .thenReturn( + fakeTraversedTreeView.copy( + nextActionStrategy = + TraversalStrategy.STOP_AND_DROP_NODE + ) + ) + + // When + testedSnapshotProducer.produce( + mockRoot, + fakeSystemInformation, + fakeTextAndInputPrivacy, + fakeImagePrivacy, + mockRecordedDataQueueRefs + ) + + // Then + argumentCaptor<() -> String> { + verify(mockInternalLogger, times(2)).log( + eq(InternalLogger.Level.ERROR), + eq(listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY)), + capture(), + any(), + eq(false), + eq(null) + ) + assertThat(lastValue.invoke()).isEqualTo(INVALID_PRIVACY_LEVEL_ERROR) + } + } + // region Internals private fun View.toNode(