From 601398b4d143777f5372e9516d1b28e40c76f754 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:24:31 +0300 Subject: [PATCH] RUM-6218: Add privacy override for hidden views --- .../api/apiSurface | 8 +- .../api/dd-sdk-android-session-replay.api | 13 +- .../android/sessionreplay/ImagePrivacy.kt | 2 +- .../android/sessionreplay/PrivacyLevel.kt | 13 ++ .../PrivacyOverrideExtensions.kt | 25 +++ .../sessionreplay/TextAndInputPrivacy.kt | 2 +- .../android/sessionreplay/TouchPrivacy.kt | 2 +- .../sessionreplay/internal/PrivacyOverride.kt | 18 ++ .../internal/PrivacyOverrideManager.kt | 107 ++++++++++++ .../recorder/SessionReplayRecorder.kt | 5 + .../internal/recorder/TreeViewTraversal.kt | 63 ++++--- .../recorder/mapper/HiddenViewMapper.kt | 50 ++++++ .../PrivacyOverrideManagerTest.kt | 159 ++++++++++++++++++ .../sessionreplay/forge/ForgeConfigurator.kt | 1 + .../forge/PrivacyLevelForgeryFactory.kt | 27 +++ .../recorder/TreeViewTraversalTest.kt | 12 ++ .../recorder/mapper/HiddenViewMapperTest.kt | 117 +++++++++++++ 17 files changed, 588 insertions(+), 36 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyLevel.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensions.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/PrivacyOverride.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/PrivacyOverrideManager.kt create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideManagerTest.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/PrivacyLevelForgeryFactory.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapperTest.kt diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index dae425c488..6890b78f56 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -1,7 +1,7 @@ interface com.datadog.android.sessionreplay.ExtensionSupport fun getCustomViewMappers(): List> fun getOptionSelectorDetectors(): List -enum com.datadog.android.sessionreplay.ImagePrivacy +enum com.datadog.android.sessionreplay.ImagePrivacy : PrivacyLevel - MASK_NONE - MASK_LARGE_ONLY - MASK_ALL @@ -9,6 +9,8 @@ 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 +interface com.datadog.android.sessionreplay.PrivacyLevel +fun android.view.View.ddSessionReplayOverrideHidden(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()) @@ -28,11 +30,11 @@ enum com.datadog.android.sessionreplay.SessionReplayPrivacy - ALLOW - MASK - MASK_USER_INPUT -enum com.datadog.android.sessionreplay.TextAndInputPrivacy +enum com.datadog.android.sessionreplay.TextAndInputPrivacy : PrivacyLevel - MASK_SENSITIVE_INPUTS - MASK_ALL_INPUTS - MASK_ALL -enum com.datadog.android.sessionreplay.TouchPrivacy +enum com.datadog.android.sessionreplay.TouchPrivacy : PrivacyLevel - SHOW - HIDE data class com.datadog.android.sessionreplay.recorder.MappingContext 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 5746219517..1f52b96c7d 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 @@ -3,7 +3,7 @@ public abstract interface class com/datadog/android/sessionreplay/ExtensionSuppo public abstract fun getOptionSelectorDetectors ()Ljava/util/List; } -public final class com/datadog/android/sessionreplay/ImagePrivacy : java/lang/Enum { +public final class com/datadog/android/sessionreplay/ImagePrivacy : java/lang/Enum, com/datadog/android/sessionreplay/PrivacyLevel { public static final field MASK_ALL Lcom/datadog/android/sessionreplay/ImagePrivacy; public static final field MASK_LARGE_ONLY Lcom/datadog/android/sessionreplay/ImagePrivacy; public static final field MASK_NONE Lcom/datadog/android/sessionreplay/ImagePrivacy; @@ -22,6 +22,13 @@ public final class com/datadog/android/sessionreplay/MapperTypeWrapper { public fun toString ()Ljava/lang/String; } +public abstract interface class com/datadog/android/sessionreplay/PrivacyLevel { +} + +public final class com/datadog/android/sessionreplay/PrivacyOverrideExtensionsKt { + public static final fun ddSessionReplayOverrideHidden (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 @@ -61,7 +68,7 @@ public final class com/datadog/android/sessionreplay/SessionReplayPrivacy : java public static fun values ()[Lcom/datadog/android/sessionreplay/SessionReplayPrivacy; } -public final class com/datadog/android/sessionreplay/TextAndInputPrivacy : java/lang/Enum { +public final class com/datadog/android/sessionreplay/TextAndInputPrivacy : java/lang/Enum, com/datadog/android/sessionreplay/PrivacyLevel { public static final field MASK_ALL Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; public static final field MASK_ALL_INPUTS Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; public static final field MASK_SENSITIVE_INPUTS Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; @@ -69,7 +76,7 @@ public final class com/datadog/android/sessionreplay/TextAndInputPrivacy : java/ public static fun values ()[Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; } -public final class com/datadog/android/sessionreplay/TouchPrivacy : java/lang/Enum { +public final class com/datadog/android/sessionreplay/TouchPrivacy : java/lang/Enum, com/datadog/android/sessionreplay/PrivacyLevel { public static final field HIDE Lcom/datadog/android/sessionreplay/TouchPrivacy; public static final field SHOW Lcom/datadog/android/sessionreplay/TouchPrivacy; public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/TouchPrivacy; diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ImagePrivacy.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ImagePrivacy.kt index 9d4c535c49..13f5f83f07 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ImagePrivacy.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ImagePrivacy.kt @@ -12,7 +12,7 @@ package com.datadog.android.sessionreplay * @see ImagePrivacy.MASK_LARGE_ONLY * @see ImagePrivacy.MASK_ALL */ -enum class ImagePrivacy { +enum class ImagePrivacy : PrivacyLevel { /** * All images will be recorded, including those downloaded from the Internet during app runtime. */ diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyLevel.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyLevel.kt new file mode 100644 index 0000000000..ed844841bc --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyLevel.kt @@ -0,0 +1,13 @@ +/* + * 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 + +/** + * Common interface for Session Replay privacy levels. + * Privacy levels determine the degree of masking applied to sessions. + */ +interface PrivacyLevel 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..5cca3a651f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensions.kt @@ -0,0 +1,25 @@ +/* + * 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.internal.PrivacyOverrideManager + +/** + * 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.ddSessionReplayOverrideHidden(hide: Boolean) { + if (hide) { + PrivacyOverrideManager.addHiddenOverride(this) + } else { + PrivacyOverrideManager.removeHiddenOverride(this) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TextAndInputPrivacy.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TextAndInputPrivacy.kt index 6825c465b6..2bcd4d22bd 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TextAndInputPrivacy.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TextAndInputPrivacy.kt @@ -12,7 +12,7 @@ package com.datadog.android.sessionreplay * @see TextAndInputPrivacy.MASK_ALL_INPUTS * @see TextAndInputPrivacy.MASK_ALL */ -enum class TextAndInputPrivacy { +enum class TextAndInputPrivacy : PrivacyLevel { /** * All text and inputs considered sensitive will be masked. diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TouchPrivacy.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TouchPrivacy.kt index 5c7eeb1d1f..b89dfc4e57 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TouchPrivacy.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TouchPrivacy.kt @@ -11,7 +11,7 @@ package com.datadog.android.sessionreplay * @see TouchPrivacy.SHOW * @see TouchPrivacy.HIDE */ -enum class TouchPrivacy { +enum class TouchPrivacy : PrivacyLevel { /** * All touch interactions will be recorded. */ diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/PrivacyOverride.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/PrivacyOverride.kt new file mode 100644 index 0000000000..02de43cefe --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/PrivacyOverride.kt @@ -0,0 +1,18 @@ +/* + * 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 + +import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.TouchPrivacy + +internal data class PrivacyOverride( + val imagePrivacy: ImagePrivacy? = null, + val touchPrivacy: TouchPrivacy? = null, + val textAndInputPrivacy: TextAndInputPrivacy? = null, + val hiddenPrivacy: Boolean = false +) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/PrivacyOverrideManager.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/PrivacyOverrideManager.kt new file mode 100644 index 0000000000..ea401803dc --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/PrivacyOverrideManager.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.internal + +import android.view.View +import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.PrivacyLevel +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.TouchPrivacy +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap + +internal object PrivacyOverrideManager { + private val overridesMap = ConcurrentHashMap() + + internal fun addPrivacyOverride(view: View, level: PrivacyLevel?) { + val key = WeakViewKey(view) + val existingPrivacy = overridesMap[key] ?: PrivacyOverride() + + overridesMap[key] = + when (level) { + is ImagePrivacy -> existingPrivacy.copy( + imagePrivacy = level + ) + + is TextAndInputPrivacy -> existingPrivacy.copy( + textAndInputPrivacy = level + ) + + is TouchPrivacy -> existingPrivacy.copy( + touchPrivacy = level + ) + + else -> return + } + } + + internal fun removeTextAndInputPrivacyOverride(view: View) { + val key = WeakViewKey(view) + val existingPrivacy = overridesMap[key] ?: return + + overridesMap[key] = existingPrivacy.copy( + textAndInputPrivacy = null + ) + } + + internal fun removeTouchPrivacyOverride(view: View) { + val key = WeakViewKey(view) + val existingPrivacy = overridesMap[key] ?: return + + overridesMap[key] = existingPrivacy.copy( + touchPrivacy = null + ) + } + + internal fun removeImagePrivacyOverride(view: View) { + val key = WeakViewKey(view) + val existingPrivacy = overridesMap[key] ?: return + + overridesMap[key] = existingPrivacy.copy( + imagePrivacy = null + ) + } + + internal fun addHiddenOverride(view: View) { + val key = WeakViewKey(view) + val existingPrivacy = overridesMap[key] ?: PrivacyOverride() + + overridesMap[key] = existingPrivacy.copy( + hiddenPrivacy = true + ) + } + + internal fun removeHiddenOverride(view: View) { + val key = WeakViewKey(view) + val existingPrivacy = overridesMap[key] ?: return + + overridesMap[key] = existingPrivacy.copy( + hiddenPrivacy = false + ) + } + + internal fun getPrivacyOverrides(view: View): PrivacyOverride? { + val key = WeakViewKey(view) + return overridesMap[key] + } + + internal fun isHidden(view: View): Boolean { + val key = WeakViewKey(view) + return overridesMap[key]?.hiddenPrivacy == true + } + + private class WeakViewKey(view: View) : WeakReference(view) { + override fun equals(other: Any?): Boolean { + val otherValue = (other as? WeakViewKey)?.get() + return get() == otherValue + } + + override fun hashCode(): Int { + return get()?.hashCode() ?: 0 + } + } +} 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 b3dd38acd9..18ed2ca7b8 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 @@ -172,6 +173,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..1be2c2d280 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.internal.PrivacyOverrideManager 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,37 +54,43 @@ internal class TreeViewTraversal( // try to resolve from the exhaustive type mappers var mapper = findMapperForView(view) - if (mapper != null) { - jobStatusCallback = QueueStatusCallback(recordedDataQueueRefs) - traversalStrategy = if (mapper is TraverseAllChildrenMapper) { - TraversalStrategy.TRAVERSE_ALL_CHILDREN - } else { - TraversalStrategy.STOP_AND_RETURN_NODE - } - } else if (isDecorView(view)) { - traversalStrategy = TraversalStrategy.TRAVERSE_ALL_CHILDREN - mapper = decorViewMapper - jobStatusCallback = noOpCallback - } else if (view is ViewGroup) { - traversalStrategy = TraversalStrategy.TRAVERSE_ALL_CHILDREN - mapper = defaultViewMapper - jobStatusCallback = noOpCallback - } else { + if (PrivacyOverrideManager.isHidden(view)) { traversalStrategy = TraversalStrategy.STOP_AND_RETURN_NODE - mapper = defaultViewMapper + mapper = hiddenViewMapper jobStatusCallback = noOpCallback - val viewType = view.javaClass.canonicalName ?: view.javaClass.name + } else { + if (mapper != null) { + jobStatusCallback = QueueStatusCallback(recordedDataQueueRefs) + traversalStrategy = if (mapper is TraverseAllChildrenMapper) { + TraversalStrategy.TRAVERSE_ALL_CHILDREN + } else { + TraversalStrategy.STOP_AND_RETURN_NODE + } + } else if (isDecorView(view)) { + traversalStrategy = TraversalStrategy.TRAVERSE_ALL_CHILDREN + mapper = decorViewMapper + jobStatusCallback = noOpCallback + } else if (view is ViewGroup) { + traversalStrategy = TraversalStrategy.TRAVERSE_ALL_CHILDREN + mapper = defaultViewMapper + jobStatusCallback = noOpCallback + } else { + traversalStrategy = TraversalStrategy.STOP_AND_RETURN_NODE + mapper = defaultViewMapper + jobStatusCallback = noOpCallback + val viewType = view.javaClass.canonicalName ?: view.javaClass.name - internalLogger.log( - level = InternalLogger.Level.INFO, - target = InternalLogger.Target.TELEMETRY, - messageBuilder = { "No mapper found for view $viewType" }, - throwable = null, - onlyOnce = true, - additionalProperties = mapOf( - "replay.widget.type" to viewType + internalLogger.log( + level = InternalLogger.Level.INFO, + target = InternalLogger.Target.TELEMETRY, + messageBuilder = { "No mapper found for view $viewType" }, + throwable = null, + onlyOnce = true, + additionalProperties = mapOf( + "replay.widget.type" to viewType + ) ) - ) + } } val resolvedWireframes = internalLogger.measureMethodCallPerf( 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/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideManagerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideManagerTest.kt new file mode 100644 index 0000000000..5585e2b52d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideManagerTest.kt @@ -0,0 +1,159 @@ +/* + * 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 com.datadog.android.sessionreplay.internal.PrivacyOverride +import com.datadog.android.sessionreplay.internal.PrivacyOverrideManager +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery +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.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class PrivacyOverrideManagerTest { + private lateinit var testedPrivacyOverrides: PrivacyOverrideManager + + @BeforeEach + fun setup() { + testedPrivacyOverrides = PrivacyOverrideManager + } + + @Test + fun `M mark view as overridden W addPrivacyOverride()`( + @Forgery fakePrivacy: PrivacyLevel + ) { + // Given + val fakeView = mock() + val expected = generateExpectedPrivacyOverride(fakePrivacy) + + // When + testedPrivacyOverrides.addPrivacyOverride(fakeView, fakePrivacy) + + // Then + assertThat(testedPrivacyOverrides.getPrivacyOverrides(fakeView)) + .isEqualTo(expected) + } + + @Test + fun `M remove image privacy override W setViewNotHidden()`( + forge: Forge + ) { + // Given + val fakeView = mock() + val fakeImagePrivacy = forge.aValueFrom(ImagePrivacy::class.java) + testedPrivacyOverrides.addPrivacyOverride(fakeView, fakeImagePrivacy) + + // When + testedPrivacyOverrides.removeImagePrivacyOverride(fakeView) + + // Then + assertThat(testedPrivacyOverrides.getPrivacyOverrides(fakeView)) + .isEqualTo(PrivacyOverride()) + } + + @Test + fun `M remove touch privacy override W removeTouchPrivacyOverride()`( + forge: Forge + ) { + // Given + val fakeView = mock() + val fakeImagePrivacy = forge.aValueFrom(TouchPrivacy::class.java) + testedPrivacyOverrides.addPrivacyOverride(fakeView, fakeImagePrivacy) + + // When + testedPrivacyOverrides.removeTouchPrivacyOverride(fakeView) + + // Then + assertThat(testedPrivacyOverrides.getPrivacyOverrides(fakeView)) + .isEqualTo(PrivacyOverride()) + } + + @Test + fun `M remove text and input privacy override W removeTextAndInputPrivacyOverride()`( + forge: Forge + ) { + // Given + val fakeView = mock() + val fakeImagePrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java) + testedPrivacyOverrides.addPrivacyOverride(fakeView, fakeImagePrivacy) + + // When + testedPrivacyOverrides.removeTextAndInputPrivacyOverride(fakeView) + + // Then + assertThat(testedPrivacyOverrides.getPrivacyOverrides(fakeView)) + .isEqualTo(PrivacyOverride()) + } + + // region hiddenPrivacy + + @Test + fun `M mark view as hidden W addHiddenOverride()`() { + // Given + val fakeView = mock() + val expected = PrivacyOverride().copy( + hiddenPrivacy = true + ) + + // When + testedPrivacyOverrides.addHiddenOverride(fakeView) + + // Then + assertThat(testedPrivacyOverrides.getPrivacyOverrides(fakeView)) + .isEqualTo(expected) + } + + @Test + fun `M remove hidden privacy override W removeHiddenOverride()`() { + // Given + val fakeView = mock() + testedPrivacyOverrides.addHiddenOverride(fakeView) + + // When + testedPrivacyOverrides.removeHiddenOverride(fakeView) + + // Then + assertThat(testedPrivacyOverrides.getPrivacyOverrides(fakeView)) + .isEqualTo(PrivacyOverride()) + } + + // endregion + + private fun generateExpectedPrivacyOverride(fakePrivacy: PrivacyLevel): PrivacyOverride? { + return when (fakePrivacy) { + is ImagePrivacy -> PrivacyOverride().copy( + imagePrivacy = fakePrivacy + ) + + is TextAndInputPrivacy -> PrivacyOverride().copy( + textAndInputPrivacy = fakePrivacy + ) + + is TouchPrivacy -> PrivacyOverride().copy( + touchPrivacy = fakePrivacy + ) + + else -> return null + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt index 2641cb3e3d..b92f8d6c65 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt @@ -54,6 +54,7 @@ internal class ForgeConfigurator : BaseConfigurator() { forge.addFactory(TouchEventRecordedDataQueueItemForgeryFactory()) forge.addFactory(WireframeBoundsForgeryFactory()) forge.addFactory(WebViewWireframeForgeryFactory()) + forge.addFactory(PrivacyLevelForgeryFactory()) forge.useJvmFactories() } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/PrivacyLevelForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/PrivacyLevelForgeryFactory.kt new file mode 100644 index 0000000000..435d6fa177 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/PrivacyLevelForgeryFactory.kt @@ -0,0 +1,27 @@ +/* + * 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.forge + +import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.PrivacyLevel +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.TouchPrivacy +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory +import kotlin.random.Random + +internal class PrivacyLevelForgeryFactory : + ForgeryFactory { + override fun getForgery(forge: Forge): PrivacyLevel { + return when (Random.nextInt(3)) { + 0 -> forge.aValueFrom(ImagePrivacy::class.java) + 1 -> forge.aValueFrom(TouchPrivacy::class.java) + 2 -> forge.aValueFrom(TextAndInputPrivacy::class.java) + else -> forge.aValueFrom(ImagePrivacy::class.java) // should never happen + } + } +} 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..2977bcc5c6 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapperTest.kt @@ -0,0 +1,117 @@ +/* + * 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 + ) + + val wireframe = wireframesList[0] as? MobileSegment.Wireframe.PlaceholderWireframe + + // Then + checkNotNull(wireframe) + 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() + } +}