From 5d2138125d3838a34d777e35c87b87fdc973df85 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 21 Oct 2024 14:28:38 +0300 Subject: [PATCH] RUM-6566: Implement touch privacy override --- detekt_custom.yml | 2 + .../api/apiSurface | 1 + .../api/dd-sdk-android-session-replay.api | 1 + .../PrivacyOverrideExtensions.kt | 14 ++ .../android/sessionreplay/SessionReplay.kt | 3 + .../internal/DefaultRecorderProvider.kt | 5 +- .../internal/SessionReplayFeature.kt | 4 +- .../internal/TouchPrivacyManager.kt | 72 ++++++++ .../recorder/DefaultOnDrawListenerProducer.kt | 7 +- .../recorder/OnDrawListenerProducer.kt | 4 +- .../recorder/SessionReplayRecorder.kt | 22 +-- .../internal/recorder/TreeViewTraversal.kt | 39 ++++- .../recorder/ViewOnDrawInterceptor.kt | 4 +- .../recorder/WindowCallbackInterceptor.kt | 8 +- .../callback/RecorderWindowCallback.kt | 13 +- .../listener/WindowsOnDrawListener.kt | 4 + .../src/main/res/values/ids.xml | 1 + .../PrivacyOverrideExtensionsTest.kt | 43 +++++ .../SessionReplayRecorderTest.kt | 9 +- .../internal/SessionReplayFeatureTest.kt | 5 + .../internal/TouchPrivacyManagerTest.kt | 157 ++++++++++++++++++ .../recorder/TreeViewTraversalTest.kt | 108 ++++++------ .../recorder/ViewOnDrawInterceptorTest.kt | 22 ++- .../recorder/WindowCallbackInterceptorTest.kt | 10 +- .../callback/RecorderWindowCallbackTest.kt | 21 ++- .../listener/WindowsOnDrawListenerTest.kt | 10 +- 26 files changed, 492 insertions(+), 97 deletions(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManager.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManagerTest.kt diff --git a/detekt_custom.yml b/detekt_custom.yml index 7ea0ea25bc..338497d5f1 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -470,6 +470,7 @@ datadog: - "android.graphics.drawable.RippleDrawable.findIndexByLayerId(kotlin.Int)" - "android.graphics.drawable.DrawableContainer.DrawableContainerState.getChild(kotlin.Int)" - "android.graphics.Point.constructor()" + - "android.graphics.Point.constructor(kotlin.Int, kotlin.Int)" - "android.graphics.Rect.centerX()" - "android.graphics.Rect.centerY()" - "android.graphics.Rect.constructor()" @@ -729,6 +730,7 @@ datadog: - "java.security.SecureRandom.nextFloat()" - "java.security.SecureRandom.nextInt()" - "java.security.SecureRandom.nextLong()" + - "java.util.HashMap.clear()" - "java.util.HashSet.addAll(kotlin.collections.Collection)" - "java.util.HashSet.find(kotlin.Function1)" - "java.util.LinkedList.addFirst(com.datadog.android.webview.internal.rum.domain.WebViewNativeRumViewsCache.ViewEntry?)" diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index debfc1b947..3ce3cd5549 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -13,6 +13,7 @@ interface com.datadog.android.sessionreplay.PrivacyLevel fun android.view.View.setSessionReplayHidden(Boolean) fun android.view.View.setSessionReplayImagePrivacy(ImagePrivacy?) fun android.view.View.setSessionReplayTextAndInputPrivacy(TextAndInputPrivacy?) +fun android.view.View.setSessionReplayTouchPrivacy(TouchPrivacy?) 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 e5d712484c..ac3d9ed988 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 @@ -29,6 +29,7 @@ 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 static final fun setSessionReplayTouchPrivacy (Landroid/view/View;Lcom/datadog/android/sessionreplay/TouchPrivacy;)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 312b86c120..115f711761 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 @@ -50,3 +50,17 @@ fun View.setSessionReplayTextAndInputPrivacy(privacy: TextAndInputPrivacy?) { this.setTag(R.id.datadog_text_and_input_privacy, privacy.toString()) } } + +/** + * Allows overriding the touch 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.setSessionReplayTouchPrivacy(privacy: TouchPrivacy?) { + if (privacy == null) { + this.setTag(R.id.datadog_touch_privacy, null) + } else { + this.setTag(R.id.datadog_touch_privacy, privacy.toString()) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt index 1840ab7b47..a180a4f9ea 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt @@ -11,6 +11,7 @@ import com.datadog.android.api.SdkCore import com.datadog.android.api.feature.Feature.Companion.SESSION_REPLAY_FEATURE_NAME import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.internal.SessionReplayFeature +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager /** * An entry point to Datadog Session Replay feature. @@ -35,12 +36,14 @@ object SessionReplay { val featureSdkCore = sdkCore as FeatureSdkCore sessionReplayConfiguration.systemRequirementsConfiguration .runIfRequirementsMet(featureSdkCore.internalLogger) { + val touchPrivacyManager = TouchPrivacyManager(sessionReplayConfiguration.touchPrivacy) val sessionReplayFeature = SessionReplayFeature( sdkCore = featureSdkCore, customEndpointUrl = sessionReplayConfiguration.customEndpointUrl, privacy = sessionReplayConfiguration.privacy, imagePrivacy = sessionReplayConfiguration.imagePrivacy, touchPrivacy = sessionReplayConfiguration.touchPrivacy, + touchPrivacyManager = touchPrivacyManager, textAndInputPrivacy = sessionReplayConfiguration.textAndInputPrivacy, customMappers = sessionReplayConfiguration.customMappers, customOptionSelectorDetectors = sessionReplayConfiguration.customOptionSelectorDetectors, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt index cdac483ade..506890e599 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt @@ -25,7 +25,6 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.TextAndInputPrivacy -import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.android.sessionreplay.internal.recorder.Recorder import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder import com.datadog.android.sessionreplay.internal.recorder.mapper.ActionBarContainerMapper @@ -60,7 +59,7 @@ internal class DefaultRecorderProvider( private val sdkCore: FeatureSdkCore, private val textAndInputPrivacy: TextAndInputPrivacy, private val imagePrivacy: ImagePrivacy, - private val touchPrivacy: TouchPrivacy, + private val touchPrivacyManager: TouchPrivacyManager, private val customMappers: List>, private val customOptionSelectorDetectors: List, private val dynamicOptimizationEnabled: Boolean @@ -78,7 +77,7 @@ internal class DefaultRecorderProvider( resourcesWriter = resourceWriter, rumContextProvider = SessionReplayRumContextProvider(sdkCore), imagePrivacy = imagePrivacy, - touchPrivacy = touchPrivacy, + touchPrivacyManager = touchPrivacyManager, textAndInputPrivacy = textAndInputPrivacy, recordWriter = recordWriter, timeProvider = SessionReplayTimeProvider(sdkCore), diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt index 96978f9926..753fcca4ea 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt @@ -56,12 +56,14 @@ internal class SessionReplayFeature( private val currentRumSessionId = AtomicReference() + @Suppress("LongParameterList") internal constructor( sdkCore: FeatureSdkCore, customEndpointUrl: String?, privacy: SessionReplayPrivacy, textAndInputPrivacy: TextAndInputPrivacy, touchPrivacy: TouchPrivacy, + touchPrivacyManager: TouchPrivacyManager, imagePrivacy: ImagePrivacy, customMappers: List>, customOptionSelectorDetectors: List, @@ -81,7 +83,7 @@ internal class SessionReplayFeature( sdkCore, textAndInputPrivacy, imagePrivacy, - touchPrivacy, + touchPrivacyManager, customMappers, customOptionSelectorDetectors, dynamicOptimizationEnabled diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManager.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManager.kt new file mode 100644 index 0000000000..94a96a54fe --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManager.kt @@ -0,0 +1,72 @@ +/* + * 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.graphics.Point +import android.graphics.Rect +import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting +import com.datadog.android.sessionreplay.TouchPrivacy + +internal class TouchPrivacyManager( + private val globalTouchPrivacy: TouchPrivacy +) { + // areas on screen where overrides are applied + private val currentOverrideAreas = HashMap() + + // built during the view traversal and copied to currentOverrideSnapshot at the end + private val nextOverrideAreas = HashMap() + + @UiThread + internal fun addTouchOverrideArea(bounds: Rect, touchPrivacy: TouchPrivacy) { + nextOverrideAreas[bounds] = touchPrivacy + } + + @UiThread + internal fun updateCurrentTouchOverrideSnapshot() { + currentOverrideAreas.clear() + // NPE cannot happen here + @Suppress("UnsafeThirdPartyFunctionCall") + currentOverrideAreas.putAll(nextOverrideAreas) + nextOverrideAreas.clear() + } + + @UiThread + internal fun shouldRecordTouch(touchLocation: Point): Boolean { + var isOverriddenToShowTouch = false + + // Everything is UiThread, so ConcurrentModification cannot happen here + @Suppress("UnsafeThirdPartyFunctionCall") + currentOverrideAreas.forEach { entry -> + val area = entry.key + val overrideValue = entry.value + + if (isWithinOverrideArea(touchLocation, area)) { + when (overrideValue) { + TouchPrivacy.HIDE -> return false + TouchPrivacy.SHOW -> isOverriddenToShowTouch = true + } + } + } + + return if (isOverriddenToShowTouch) true else globalTouchPrivacy == TouchPrivacy.SHOW + } + + @VisibleForTesting + internal fun getCurrentOverrideSnapshot(): Map { + return currentOverrideAreas + } + + @VisibleForTesting + internal fun getNextOverrideSnapshot(): Map { + return nextOverrideAreas + } + + private fun isWithinOverrideArea(touchPoint: Point, overrideArea: Rect): Boolean = + touchPoint.x in overrideArea.left..overrideArea.right && + touchPoint.y in overrideArea.top..overrideArea.bottom +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt index 94d4e88922..92a7010a37 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.listener.WindowsOnDrawListener @@ -25,7 +26,8 @@ internal class DefaultOnDrawListenerProducer( override fun create( decorViews: List, textAndInputPrivacy: TextAndInputPrivacy, - imagePrivacy: ImagePrivacy + imagePrivacy: ImagePrivacy, + touchPrivacyManager: TouchPrivacyManager ): ViewTreeObserver.OnDrawListener { return WindowsOnDrawListener( zOrderedDecorViews = decorViews, @@ -35,7 +37,8 @@ internal class DefaultOnDrawListenerProducer( imagePrivacy = imagePrivacy, sdkCore = sdkCore, methodCallSamplingRate = MethodCallSamplingRate.LOW.rate, - dynamicOptimizationEnabled = dynamicOptimizationEnabled + dynamicOptimizationEnabled = dynamicOptimizationEnabled, + touchPrivacyManager = touchPrivacyManager ) } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt index 69c0905e99..6bba481d39 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt @@ -10,11 +10,13 @@ import android.view.View import android.view.ViewTreeObserver import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager internal fun interface OnDrawListenerProducer { fun create( decorViews: List, textAndInputPrivacy: TextAndInputPrivacy, - imagePrivacy: ImagePrivacy + imagePrivacy: ImagePrivacy, + touchPrivacyManager: TouchPrivacyManager ): ViewTreeObserver.OnDrawListener } 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 a3f7ecd630..e7a4badf0b 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 @@ -17,9 +17,9 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.TextAndInputPrivacy -import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.android.sessionreplay.internal.LifecycleCallback import com.datadog.android.sessionreplay.internal.SessionReplayLifecycleCallback +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.processor.MutationResolver import com.datadog.android.sessionreplay.internal.processor.RecordedDataProcessor @@ -58,7 +58,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { private val rumContextProvider: RumContextProvider private val textAndInputPrivacy: TextAndInputPrivacy private val imagePrivacy: ImagePrivacy - private val touchPrivacy: TouchPrivacy + private val touchPrivacyManager: TouchPrivacyManager private val recordWriter: RecordWriter private val timeProvider: TimeProvider private val mappers: List> @@ -80,7 +80,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { rumContextProvider: RumContextProvider, textAndInputPrivacy: TextAndInputPrivacy, imagePrivacy: ImagePrivacy, - touchPrivacy: TouchPrivacy, + touchPrivacyManager: TouchPrivacyManager, recordWriter: RecordWriter, timeProvider: TimeProvider, mappers: List> = emptyList(), @@ -110,7 +110,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { this.rumContextProvider = rumContextProvider this.textAndInputPrivacy = textAndInputPrivacy this.imagePrivacy = imagePrivacy - this.touchPrivacy = touchPrivacy + this.touchPrivacyManager = touchPrivacyManager this.recordWriter = recordWriter this.timeProvider = timeProvider this.mappers = mappers @@ -179,7 +179,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { viewIdentifierResolver = viewIdentifierResolver ), viewUtilsInternal = ViewUtilsInternal(), - internalLogger = internalLogger + internalLogger = internalLogger, + touchPrivacyManager = touchPrivacyManager ), ComposedOptionSelectorDetector( customOptionSelectorDetectors + DefaultOptionSelectorDetector() @@ -189,7 +190,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { recordedDataQueueHandler = recordedDataQueueHandler, sdkCore = sdkCore, dynamicOptimizationEnabled = dynamicOptimizationEnabled - ) + ), + touchPrivacyManager = touchPrivacyManager ) this.windowCallbackInterceptor = WindowCallbackInterceptor( recordedDataQueueHandler, @@ -197,8 +199,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { timeProvider, internalLogger, imagePrivacy, - touchPrivacy, - textAndInputPrivacy + textAndInputPrivacy, + touchPrivacyManager ) this.sessionReplayLifecycleCallback = SessionReplayLifecycleCallback(this) this.uiHandler = Handler(Looper.getMainLooper()) @@ -212,7 +214,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { rumContextProvider: RumContextProvider, textAndInputPrivacy: TextAndInputPrivacy, imagePrivacy: ImagePrivacy, - touchPrivacy: TouchPrivacy, + touchPrivacyManager: TouchPrivacyManager, recordWriter: RecordWriter, timeProvider: TimeProvider, mappers: List> = emptyList(), @@ -230,7 +232,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { this.rumContextProvider = rumContextProvider this.textAndInputPrivacy = textAndInputPrivacy this.imagePrivacy = imagePrivacy - this.touchPrivacy = touchPrivacy + this.touchPrivacyManager = touchPrivacyManager this.recordWriter = recordWriter this.timeProvider = timeProvider this.mappers = mappers 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 7071488119..b2a550bfcb 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 @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.recorder +import android.graphics.Rect import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread @@ -14,6 +15,9 @@ 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.TouchPrivacy +import com.datadog.android.sessionreplay.internal.PrivacyHelper +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager 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 @@ -23,6 +27,7 @@ import com.datadog.android.sessionreplay.recorder.mapper.TraverseAllChildrenMapp import com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.NoOpAsyncJobStatusCallback +import java.util.Locale internal class TreeViewTraversal( private val mappers: List>, @@ -30,7 +35,9 @@ internal class TreeViewTraversal( private val hiddenViewMapper: HiddenViewMapper, private val decorViewMapper: WireframeMapper, private val viewUtilsInternal: ViewUtilsInternal, - private val internalLogger: InternalLogger + private val internalLogger: InternalLogger, + private val touchPrivacyManager: TouchPrivacyManager, + private val privacyHelper: PrivacyHelper = PrivacyHelper(internalLogger) ) { @Suppress("ReturnCount") @@ -53,6 +60,7 @@ internal class TreeViewTraversal( // try to resolve from the exhaustive type mappers var mapper = findMapperForView(view) + updateTouchOverrideAreas(view) if (isHidden(view)) { traversalStrategy = TraversalStrategy.STOP_AND_RETURN_NODE @@ -115,6 +123,35 @@ internal class TreeViewTraversal( private fun isHidden(view: View): Boolean = view.getTag(R.id.datadog_hidden) == true + @UiThread + private fun updateTouchOverrideAreas(view: View) { + val touchPrivacy = view.getTag(R.id.datadog_touch_privacy) + + if (touchPrivacy != null) { + val locationOnScreen = IntArray(2) + + // this will always have size >= 2 + @Suppress("UnsafeThirdPartyFunctionCall") + view.getLocationOnScreen(locationOnScreen) + + val x = locationOnScreen[0] + val y = locationOnScreen[1] + val viewArea = Rect( + x - view.paddingLeft, + y - view.paddingTop, + x + view.width + view.paddingRight, + y + view.height + view.paddingBottom + ) + + try { + val privacyLevel = TouchPrivacy.valueOf(touchPrivacy.toString().uppercase(Locale.US)) + touchPrivacyManager.addTouchOverrideArea(viewArea, privacyLevel) + } catch (e: IllegalArgumentException) { + privacyHelper.logInvalidPrivacyLevelError(e) + } + } + } + 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/ViewOnDrawInterceptor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt index b1ee4b2bb9..4686ed185e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt @@ -11,10 +11,12 @@ import android.view.ViewTreeObserver.OnDrawListener import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import java.util.WeakHashMap internal class ViewOnDrawInterceptor( private val internalLogger: InternalLogger, + private val touchPrivacyManager: TouchPrivacyManager, private val onDrawListenerProducer: OnDrawListenerProducer ) { internal val decorOnDrawListeners: WeakHashMap = @@ -27,7 +29,7 @@ internal class ViewOnDrawInterceptor( ) { stopInterceptingAndRemove(decorViews) val onDrawListener = - onDrawListenerProducer.create(decorViews, textAndInputPrivacy, imagePrivacy) + onDrawListenerProducer.create(decorViews, textAndInputPrivacy, imagePrivacy, touchPrivacyManager) decorViews.forEach { decorView -> val viewTreeObserver = decorView.viewTreeObserver if (viewTreeObserver != null && viewTreeObserver.isAlive) { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptor.kt index bf465a9845..d1a4989874 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptor.kt @@ -11,7 +11,7 @@ import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy -import com.datadog.android.sessionreplay.TouchPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.callback.NoOpWindowCallback import com.datadog.android.sessionreplay.internal.recorder.callback.RecorderWindowCallback @@ -24,8 +24,8 @@ internal class WindowCallbackInterceptor( private val timeProvider: TimeProvider, private val internalLogger: InternalLogger, private val imagePrivacy: ImagePrivacy, - private val touchPrivacy: TouchPrivacy, - private val textAndInputPrivacy: TextAndInputPrivacy + private val textAndInputPrivacy: TextAndInputPrivacy, + private val touchPrivacyManager: TouchPrivacyManager ) { private val wrappedWindows: WeakHashMap = WeakHashMap() @@ -61,7 +61,7 @@ internal class WindowCallbackInterceptor( internalLogger, textAndInputPrivacy, imagePrivacy, - touchPrivacy + touchPrivacyManager ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallback.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallback.kt index 010c54b87f..40293a5ae4 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallback.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallback.kt @@ -7,13 +7,14 @@ package com.datadog.android.sessionreplay.internal.recorder.callback import android.content.Context +import android.graphics.Point import android.view.MotionEvent import android.view.Window import androidx.annotation.MainThread import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy -import com.datadog.android.sessionreplay.TouchPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.ViewOnDrawInterceptor import com.datadog.android.sessionreplay.internal.recorder.WindowInspector @@ -33,7 +34,7 @@ internal class RecorderWindowCallback( private val internalLogger: InternalLogger, private val privacy: TextAndInputPrivacy, private val imagePrivacy: ImagePrivacy, - private val touchPrivacy: TouchPrivacy, + private val touchPrivacyManager: TouchPrivacyManager, private val copyEvent: (MotionEvent) -> MotionEvent = { @Suppress("UnsafeThirdPartyFunctionCall") // NPE cannot happen here MotionEvent.obtain(it) @@ -47,13 +48,19 @@ internal class RecorderWindowCallback( internal var pointerInteractions: MutableList = LinkedList() private var lastOnMoveUpdateTimeInNs: Long = 0L private var lastPerformedFlushTimeInNs: Long = System.nanoTime() + private var shouldRecordMotion: Boolean = false // region Window.Callback @MainThread override fun dispatchTouchEvent(event: MotionEvent?): Boolean { if (event != null) { - if (touchPrivacy == TouchPrivacy.SHOW) { + if (event.action == MotionEvent.ACTION_DOWN) { + val touchLocation = Point(event.x.toInt(), event.y.toInt()) + shouldRecordMotion = touchPrivacyManager.shouldRecordTouch(touchLocation) + } + + if (shouldRecordMotion) { // we copy it and delegate it to the gesture detector for analysis @Suppress("UnsafeThirdPartyFunctionCall") // internal safe call val copy = copyEvent(event) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt index ea36bc39cb..ebb58676f0 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt @@ -14,6 +14,7 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.feature.measureMethodCallPerf import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.Debouncer @@ -31,6 +32,7 @@ internal class WindowsOnDrawListener( private val miscUtils: MiscUtils = MiscUtils, private val sdkCore: FeatureSdkCore, dynamicOptimizationEnabled: Boolean, + private val touchPrivacyManager: TouchPrivacyManager, private val debouncer: Debouncer = Debouncer( sdkCore = sdkCore, dynamicOptimizationEnabled = dynamicOptimizationEnabled @@ -86,6 +88,8 @@ internal class WindowsOnDrawListener( if (item.isReady()) { recordedDataQueueHandler.tryToConsumeItems() } + + touchPrivacyManager.updateCurrentTouchOverrideSnapshot() } } 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 71501d03f6..9aa089d8fd 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 @@ -8,4 +8,5 @@ + 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 dc6b073523..c0ad310198 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 @@ -30,6 +30,8 @@ import org.mockito.quality.Strictness @ForgeConfiguration(ForgeConfigurator::class) internal class PrivacyOverrideExtensionsTest { + // region setSessionReplayHidden + @Test fun `M set tag W setSessionReplayHidden() { hide is true }`() { // Given @@ -54,6 +56,10 @@ internal class PrivacyOverrideExtensionsTest { verify(mockView).setTag(eq(R.id.datadog_hidden), isNull()) } + // endregion + + // region setSessionReplayImagePrivacy + @Test fun `M set tag W setSessionReplayImagePrivacy() { with privacy }`( forge: Forge @@ -81,6 +87,10 @@ internal class PrivacyOverrideExtensionsTest { verify(mockView).setTag(eq(R.id.datadog_image_privacy), isNull()) } + // endregion + + // region setSessionReplayTextAndInputPrivacy + @Test fun `M set tag W setSessionReplayTextAndInputPrivacy() { with privacy }`( forge: Forge @@ -107,4 +117,37 @@ internal class PrivacyOverrideExtensionsTest { // Then verify(mockView).setTag(eq(R.id.datadog_text_and_input_privacy), isNull()) } + + // endregion + + // region setSessionReplayTouchPrivacy + + @Test + fun `M set tag W setSessionReplayTouchPrivacy() { with privacy }`( + forge: Forge + ) { + // Given + val mockView = mock() + val mockPrivacy = forge.aValueFrom(TouchPrivacy::class.java) + + // When + mockView.setSessionReplayTouchPrivacy(mockPrivacy) + + // Then + verify(mockView).setTag(eq(R.id.datadog_touch_privacy), eq(mockPrivacy.toString())) + } + + @Test + fun `M set tag to null W setSessionReplayTouchPrivacy() { privacy is null }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayTouchPrivacy(null) + + // Then + verify(mockView).setTag(eq(R.id.datadog_touch_privacy), isNull()) + } + + // endregion } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt index b22f526ec9..54963f1a11 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt @@ -12,6 +12,7 @@ import android.view.View import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.internal.LifecycleCallback +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder import com.datadog.android.sessionreplay.internal.recorder.ViewOnDrawInterceptor @@ -65,9 +66,6 @@ internal class SessionReplayRecorderTest { @Forgery private lateinit var fakeImagePrivacy: ImagePrivacy - @Forgery - private lateinit var fakeTouchPrivacy: TouchPrivacy - @Mock private lateinit var mockTimeProvider: TimeProvider @@ -86,6 +84,9 @@ internal class SessionReplayRecorderTest { @Mock lateinit var mockInternalLogger: InternalLogger + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock lateinit var mockDataStoreManager: ResourceDataStoreManager @@ -112,7 +113,7 @@ internal class SessionReplayRecorderTest { rumContextProvider = mockRumContextProvider, textAndInputPrivacy = fakeTextAndInputPrivacy, imagePrivacy = fakeImagePrivacy, - touchPrivacy = fakeTouchPrivacy, + touchPrivacyManager = mockTouchPrivacyManager, recordWriter = mockRecordWriter, timeProvider = mockTimeProvider, mappers = mock(), diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt index d4bf1a2aa1..dd804aaece 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt @@ -83,6 +83,9 @@ internal class SessionReplayFeatureTest { @Mock lateinit var mockInternalLogger: InternalLogger + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock lateinit var mockExecutorService: ExecutorService @@ -133,6 +136,7 @@ internal class SessionReplayFeatureTest { textAndInputPrivacy = fakeConfiguration.textAndInputPrivacy, imagePrivacy = fakeConfiguration.imagePrivacy, touchPrivacy = fakeConfiguration.touchPrivacy, + touchPrivacyManager = mockTouchPrivacyManager, customMappers = emptyList(), customOptionSelectorDetectors = emptyList(), startRecordingImmediately = true, @@ -158,6 +162,7 @@ internal class SessionReplayFeatureTest { textAndInputPrivacy = fakeConfiguration.textAndInputPrivacy, imagePrivacy = fakeConfiguration.imagePrivacy, touchPrivacy = fakeConfiguration.touchPrivacy, + touchPrivacyManager = mockTouchPrivacyManager, customMappers = emptyList(), customOptionSelectorDetectors = emptyList(), sampleRate = fakeConfiguration.sampleRate, diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManagerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManagerTest.kt new file mode 100644 index 0000000000..4b4efcbaf8 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManagerTest.kt @@ -0,0 +1,157 @@ +/* + * 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.graphics.Point +import android.graphics.Rect +import com.datadog.android.sessionreplay.TouchPrivacy +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.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.Mockito.mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class TouchPrivacyManagerTest { + private lateinit var testedManager: TouchPrivacyManager + + @BeforeEach + fun `set up`(forge: Forge) { + val fakeGlobalPrivacy = forge.aValueFrom(TouchPrivacy::class.java) + testedManager = TouchPrivacyManager(fakeGlobalPrivacy) + } + + @Test + fun `M add to nextAreasSnapshot W addTouchOverrideArea()`( + forge: Forge + ) { + // Given + val fakePrivacyOverride = forge.aValueFrom(TouchPrivacy::class.java) + val mockOverrideArea = mock() + + // When + testedManager.addTouchOverrideArea(mockOverrideArea, fakePrivacyOverride) + + // Then + assertThat(testedManager.getNextOverrideSnapshot()[mockOverrideArea]).isEqualTo(fakePrivacyOverride) + } + + @Test + fun `M replace currentAreas W updateCurrentTouchOverrideSnapshot()`( + forge: Forge + ) { + // Given + val fakePrivacyOverride = forge.aValueFrom(TouchPrivacy::class.java) + val mockOverrideArea = mock() + testedManager.addTouchOverrideArea(mockOverrideArea, fakePrivacyOverride) + assertThat(testedManager.getNextOverrideSnapshot()[mockOverrideArea]).isEqualTo(fakePrivacyOverride) + + // When + testedManager.updateCurrentTouchOverrideSnapshot() + + // Then + assertThat(testedManager.getCurrentOverrideSnapshot()[mockOverrideArea]).isEqualTo(fakePrivacyOverride) + assertThat(testedManager.getNextOverrideSnapshot()).isEmpty() + } + + @Test + fun `M return override W shouldRecordTouch() { within override area }`( + forge: Forge + ) { + // Given + testedManager = TouchPrivacyManager(TouchPrivacy.HIDE) + val fakePrivacyOverride = TouchPrivacy.SHOW + val touchLocation = Point( + forge.aPositiveInt(), + forge.aPositiveInt() + ) + + val overrideArea = Rect( + touchLocation.x - forge.aPositiveInt(), + touchLocation.y - forge.aPositiveInt(), + touchLocation.x + forge.aPositiveInt(), + touchLocation.y + forge.aPositiveInt() + ) + + testedManager.addTouchOverrideArea(overrideArea, fakePrivacyOverride) + testedManager.updateCurrentTouchOverrideSnapshot() + + // Then + assertThat(testedManager.shouldRecordTouch(touchLocation)).isTrue() + } + + @Test + fun `M use global privacy W shouldRecordTouch() { outside override area }`( + forge: Forge + ) { + // Given + testedManager = TouchPrivacyManager(TouchPrivacy.SHOW) + val fakeTouchX = forge.aPositiveInt() + val fakeTouchY = forge.aPositiveInt() + val fakePoint = mock() + fakePoint.x = fakeTouchX + fakePoint.y = fakeTouchY + + val fakeOverrideArea = Rect( + fakeTouchX + 1, + fakeTouchY + 1, + fakeTouchX + 100, + fakeTouchY + 100 + ) + + testedManager.addTouchOverrideArea(fakeOverrideArea, TouchPrivacy.HIDE) + testedManager.updateCurrentTouchOverrideSnapshot() + + // Then + assertThat(testedManager.shouldRecordTouch(fakePoint)).isTrue() + } + + @Test + fun `M return false W shouldRecordTouch { matches both HIDE and SHOW }`( + forge: Forge + ) { + // Given + val touchLocation = Point( + forge.aPositiveInt(), + forge.aPositiveInt() + ) + + val hiddenTouchArea = Rect( + touchLocation.x - forge.aPositiveInt(), + touchLocation.y - forge.aPositiveInt(), + touchLocation.x + forge.aPositiveInt(), + touchLocation.y + forge.aPositiveInt() + ) + + val shownTouchArea = Rect( + touchLocation.x - forge.aPositiveInt(), + touchLocation.y - forge.aPositiveInt(), + touchLocation.x + forge.aPositiveInt(), + touchLocation.y + forge.aPositiveInt() + ) + + testedManager.addTouchOverrideArea(hiddenTouchArea, TouchPrivacy.HIDE) + testedManager.addTouchOverrideArea(shownTouchArea, TouchPrivacy.SHOW) + testedManager.updateCurrentTouchOverrideSnapshot() + + // Then + assertThat(testedManager.shouldRecordTouch(touchLocation)).isFalse() + } +} 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 b7ddd804f4..a58a283d91 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 @@ -21,6 +21,7 @@ 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.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager 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 @@ -72,6 +73,9 @@ internal class TreeViewTraversalTest { @Mock lateinit var mockHiddenViewMapper: HiddenViewMapper + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock lateinit var mockViewUtilsInternal: ViewUtilsInternal @@ -86,12 +90,13 @@ internal class TreeViewTraversalTest { whenever(mockViewUtilsInternal.isNotVisible(any())).thenReturn(false) whenever(mockViewUtilsInternal.isSystemNoise(any())).thenReturn(false) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockHiddenViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) } @@ -126,12 +131,13 @@ internal class TreeViewTraversalTest { ) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - fakeTypeMapperWrappers, - mockDefaultViewMapper, - mockHiddenViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = fakeTypeMapperWrappers, + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -160,12 +166,13 @@ internal class TreeViewTraversalTest { whenever(mockDefaultViewMapper.map(eq(mockView), eq(fakeMappingContext), any(), eq(mockInternalLogger))) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockHiddenViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -195,12 +202,13 @@ internal class TreeViewTraversalTest { whenever(mockDefaultViewMapper.map(eq(mockView), eq(fakeMappingContext), any(), eq(mockInternalLogger))) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockHiddenViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -245,12 +253,13 @@ internal class TreeViewTraversalTest { ) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - fakeTypeMapperWrappers, - mockDefaultViewMapper, - mockHiddenViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = fakeTypeMapperWrappers, + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -279,12 +288,13 @@ internal class TreeViewTraversalTest { whenever(mockDecorViewMapper.map(eq(mockView), eq(fakeMappingContext), any(), eq(mockInternalLogger))) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockHiddenViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -314,12 +324,13 @@ internal class TreeViewTraversalTest { whenever(mockDecorViewMapper.map(eq(mockView), eq(fakeMappingContext), any(), eq(mockInternalLogger))) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockHiddenViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -451,12 +462,13 @@ internal class TreeViewTraversalTest { whenever(mockMapper.getUnsafeMapper()).thenReturn(mockWireFrameMapper) testedTreeViewTraversal = TreeViewTraversal( - listOf(mockMapper), - mockDefaultViewMapper, - mockHiddenViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = listOf(mockMapper), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt index 2503081ba3..22f0857bfd 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -53,6 +54,9 @@ internal class ViewOnDrawInterceptorTest { @Mock lateinit var mockOnDrawListener: ViewTreeObserver.OnDrawListener + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Forgery lateinit var fakeTextAndInputPrivacy: TextAndInputPrivacy @@ -67,15 +71,17 @@ internal class ViewOnDrawInterceptorTest { whenever( mockOnDrawListenerProducer.create( - fakeDecorViews, - fakeTextAndInputPrivacy, - fakeImagePrivacy + decorViews = fakeDecorViews, + textAndInputPrivacy = fakeTextAndInputPrivacy, + imagePrivacy = fakeImagePrivacy, + touchPrivacyManager = mockTouchPrivacyManager ) ) doReturn mockOnDrawListener testedInterceptor = ViewOnDrawInterceptor( internalLogger = mockInternalLogger, - onDrawListenerProducer = mockOnDrawListenerProducer + onDrawListenerProducer = mockOnDrawListenerProducer, + touchPrivacyManager = mockTouchPrivacyManager ) } @@ -98,7 +104,8 @@ internal class ViewOnDrawInterceptorTest { // Given testedInterceptor = ViewOnDrawInterceptor( internalLogger = mockInternalLogger, - onDrawListenerProducer = { _, privacy, _ -> + touchPrivacyManager = mockTouchPrivacyManager, + onDrawListenerProducer = { _, privacy, _, _ -> check(privacy == fakeTextAndInputPrivacy) { "Expected to create an OnDrawListener with privacy $fakeTextAndInputPrivacy but was $privacy" } @@ -120,8 +127,9 @@ internal class ViewOnDrawInterceptorTest { // Given val mockOnDrawListener = mock() testedInterceptor = ViewOnDrawInterceptor( - internalLogger = mockInternalLogger - ) { _, _, _ -> mockOnDrawListener } + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager + ) { _, _, _, _ -> mockOnDrawListener } // When testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptorTest.kt index 68d6853d19..07f758e020 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptorTest.kt @@ -14,8 +14,8 @@ import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy -import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.callback.NoOpWindowCallback import com.datadog.android.sessionreplay.internal.recorder.callback.RecorderWindowCallback @@ -70,8 +70,8 @@ internal class WindowCallbackInterceptorTest { @Forgery lateinit var fakeImagePrivacy: ImagePrivacy - @Forgery - lateinit var fakeTouchPrivacy: TouchPrivacy + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager private lateinit var fakeWindowsList: List @@ -87,8 +87,8 @@ internal class WindowCallbackInterceptorTest { timeProvider = mockTimeProvider, internalLogger = mockInternalLogger, imagePrivacy = fakeImagePrivacy, - touchPrivacy = fakeTouchPrivacy, - textAndInputPrivacy = fakeTextAndInputPrivacy + textAndInputPrivacy = fakeTextAndInputPrivacy, + touchPrivacyManager = mockTouchPrivacyManager ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt index 05afb67ddf..b08e24870d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt @@ -15,8 +15,8 @@ import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy -import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.async.TouchEventRecordedDataQueueItem import com.datadog.android.sessionreplay.internal.recorder.ViewOnDrawInterceptor @@ -73,6 +73,9 @@ internal class RecorderWindowCallbackTest { @Mock lateinit var mockViewOnDrawInterceptor: ViewOnDrawInterceptor + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock lateinit var mockTimeProvider: TimeProvider @@ -114,6 +117,8 @@ internal class RecorderWindowCallbackTest { .thenReturn(fakeTouchEventRecordedDataQueueItem) whenever(mockContext.resources).thenReturn(mockResources) whenever(mockTimeProvider.getDeviceTimestamp()).thenReturn(fakeTimestamp) + whenever(mockTouchPrivacyManager.shouldRecordTouch(any())) + .thenReturn(true) testedWindowCallback = RecorderWindowCallback( appContext = mockContext, recordedDataQueueHandler = mockRecordedDataQueueHandler, @@ -122,7 +127,7 @@ internal class RecorderWindowCallbackTest { viewOnDrawInterceptor = mockViewOnDrawInterceptor, internalLogger = mockInternalLogger, imagePrivacy = ImagePrivacy.MASK_NONE, - touchPrivacy = TouchPrivacy.SHOW, + touchPrivacyManager = mockTouchPrivacyManager, privacy = fakeTextAndInputPrivacy, copyEvent = { it }, motionEventUtils = mockEventUtils, @@ -230,6 +235,10 @@ internal class RecorderWindowCallbackTest { @Test fun `M update the positions and flush them W onTouchEvent() { ActionUp }`(forge: Forge) { // Given + val fakeDownEvent = forge.touchRecords(MobileSegment.PointerEventType.DOWN) + val downMotionEvent = fakeDownEvent.asMotionEvent() + testedWindowCallback.dispatchTouchEvent(downMotionEvent) + val fakeRecords = forge.touchRecords(MobileSegment.PointerEventType.UP) val relatedMotionEvent = fakeRecords.asMotionEvent() @@ -238,7 +247,7 @@ internal class RecorderWindowCallbackTest { // Then assertThat(testedWindowCallback.pointerInteractions).isEmpty() - verify(mockRecordedDataQueueHandler).addTouchEventItem(fakeRecords) + verify(mockRecordedDataQueueHandler).addTouchEventItem(fakeDownEvent + fakeRecords) verify(mockRecordedDataQueueHandler).tryToConsumeItems() } @@ -468,7 +477,7 @@ internal class RecorderWindowCallbackTest { internalLogger = mockInternalLogger, privacy = fakeTextAndInputPrivacy, imagePrivacy = ImagePrivacy.MASK_NONE, - touchPrivacy = TouchPrivacy.SHOW, + touchPrivacyManager = mockTouchPrivacyManager, copyEvent = { it }, motionEventUtils = mockEventUtils, motionUpdateThresholdInNs = TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS, @@ -499,6 +508,8 @@ internal class RecorderWindowCallbackTest { val relatedMotionEvent2 = fakeEvent2Records.asMotionEvent() val fakeEvent3Records = forge.touchRecords(MobileSegment.PointerEventType.UP) val relatedMotionEvent3 = fakeEvent3Records.asMotionEvent() + whenever(mockTouchPrivacyManager.shouldRecordTouch(any())) + .thenReturn(false) testedWindowCallback = RecorderWindowCallback( appContext = mockContext, @@ -509,7 +520,7 @@ internal class RecorderWindowCallbackTest { internalLogger = mockInternalLogger, privacy = fakeTextAndInputPrivacy, imagePrivacy = ImagePrivacy.MASK_NONE, - touchPrivacy = TouchPrivacy.HIDE, + touchPrivacyManager = mockTouchPrivacyManager, copyEvent = { it }, motionEventUtils = mockEventUtils, motionUpdateThresholdInNs = TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS, diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt index 12e9fa55cd..cb9b30eefe 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt @@ -18,6 +18,7 @@ import com.datadog.android.core.metrics.TelemetryMetricType import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.async.SnapshotRecordedDataQueueItem @@ -78,6 +79,9 @@ internal class WindowsOnDrawListenerTest { @Mock lateinit var mockDebouncer: Debouncer + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock lateinit var mockInternalLogger: InternalLogger @@ -173,7 +177,8 @@ internal class WindowsOnDrawListenerTest { miscUtils = mockMiscUtils, sdkCore = mockSdkCore, methodCallSamplingRate = fakeMethodCallSamplingRate, - dynamicOptimizationEnabled = fakeDynamicOptimizationEnabled + dynamicOptimizationEnabled = fakeDynamicOptimizationEnabled, + touchPrivacyManager = mockTouchPrivacyManager ) } @@ -226,7 +231,8 @@ internal class WindowsOnDrawListenerTest { miscUtils = mockMiscUtils, sdkCore = mockSdkCore, methodCallSamplingRate = fakeMethodCallSamplingRate, - dynamicOptimizationEnabled = fakeDynamicOptimizationEnabled + dynamicOptimizationEnabled = fakeDynamicOptimizationEnabled, + touchPrivacyManager = mockTouchPrivacyManager ) testedListener.onDraw()