diff --git a/detekt_custom_safe_calls_android.yml b/detekt_custom_safe_calls_android.yml index 33efb7073a..2c337eabb8 100644 --- a/detekt_custom_safe_calls_android.yml +++ b/detekt_custom_safe_calls_android.yml @@ -153,8 +153,10 @@ datadog: - "android.view.ViewTreeObserver.OnDrawListener.onDraw()" - "android.view.ViewTreeObserver.addOnGlobalLayoutListener()" - "android.view.ViewTreeObserver.addOnGlobalLayoutListener(android.view.ViewTreeObserver.OnGlobalLayoutListener)" + - "android.view.ViewTreeObserver.addOnGlobalLayoutListener(android.view.ViewTreeObserver.OnGlobalLayoutListener?)" - "android.view.ViewTreeObserver.removeOnGlobalLayoutListener()" - "android.view.ViewTreeObserver.removeOnGlobalLayoutListener(android.view.ViewTreeObserver.OnGlobalLayoutListener)" + - "android.view.ViewTreeObserver.removeOnGlobalLayoutListener(android.view.ViewTreeObserver.OnGlobalLayoutListener?)" - "android.view.Window.Callback.dispatchKeyEvent(android.view.KeyEvent?)" - "android.view.Window.Callback.dispatchTouchEvent(android.view.MotionEvent?)" - "android.view.Window.Callback.onMenuItemSelected(kotlin.Int, android.view.MenuItem)" diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index e8540d875e..bc93554dca 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -397,6 +397,7 @@ datadog: - "java.util.WeakHashMap.getOrPut(android.app.Activity?, kotlin.Function0)" - "java.util.WeakHashMap.put(android.app.Activity?, android.view.ViewTreeObserver.OnDrawListener?)" - "java.util.WeakHashMap.put(android.app.Activity?, com.datadog.android.rum.internal.utils.window.RumWindowCallbackListener?)" + - "java.util.WeakHashMap.put(android.view.Window?, kotlin.Any?)" - "java.util.WeakHashMap.remove(android.app.Activity?)" - "java.util.WeakHashMap.remove(android.view.View?)" - "java.util.WeakHashMap.remove(android.view.Window?)" @@ -410,6 +411,7 @@ datadog: - "java.util.zip.Deflater.setInput(kotlin.ByteArray?)" # endregion # region Kotlin Stdlib + - "kotlin.Float.isNaN()" - "kotlin.lazy(kotlin.Function0)" - "kotlin.lazy(kotlin.LazyThreadSafetyMode, kotlin.Function0)" - "kotlin.repeat(kotlin.Int, kotlin.Function1)" diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt index 52d19233cc..d3201cd0a0 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtils.kt @@ -44,7 +44,10 @@ internal class SemanticsUtils( private val reflectionUtils: ReflectionUtils = ReflectionUtils(), private val sampler: RateBasedSampler = RateBasedSampler(BITMAP_TELEMETRY_SAMPLE_RATE) ) { - private val backgroundResolver = BackgroundResolver(reflectionUtils, ::resolveInnerBounds) + private val backgroundResolver = BackgroundResolver( + reflectionUtils, + { node, offset -> resolveInnerBounds(node, offset) } + ) internal fun findRootSemanticsNode(view: View): SemanticsNode? { reflectionUtils.apply { @@ -384,7 +387,8 @@ internal class SemanticsUtils( text = multiParagraphCapturedText ?: resolveAnnotatedString(layoutInput.text), color = modifierColor?.value ?: layoutInput.style.color.value, textAlign = layoutInput.style.textAlign, - fontSize = layoutInput.style.fontSize.value.toLong(), + fontSize = layoutInput.style.fontSize.value + .let { if (it.isNaN()) DEFAULT_FONT_SIZE_SP else it.toLong() }, fontFamily = layoutInput.style.fontFamily, textOverflow = textOverflow ) @@ -475,6 +479,7 @@ internal class SemanticsUtils( private const val OVERFLOW_TYPE_KEY = "overflow.type" private const val ERROR_TYPE_KEY = "error.type" + internal const val DEFAULT_FONT_SIZE_SP = 12L internal const val TEXT_OVERFLOW_CLIP = 1 internal const val TEXT_OVERFLOW_ELLIPSE = 2 internal const val TEXT_OVERFLOW_VISIBLE = 3 diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt index 01ebcc9408..80b98dee99 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/utils/SemanticsUtilsTest.kt @@ -639,6 +639,34 @@ internal class SemanticsUtilsTest { assertThat(result).isEqualTo(expected) } + @Test + fun `M use default font size W resolveTextLayoutInfo {fontSize is Unspecified}`(forge: Forge) { + // Given + val testData = setupTextLayoutMocks(forge) + whenever(testData.textLayoutResult.layoutInput.style.fontSize) doReturn TextUnit.Unspecified + + // When + val result = requireNotNull(testedSemanticsUtils.resolveTextLayoutInfo(mockSemanticsNode, mockInternalLogger)) + + // Then + assertThat(result.fontSize).isEqualTo(SemanticsUtils.DEFAULT_FONT_SIZE_SP) + } + + @Test + fun `M use specified font size W resolveTextLayoutInfo {fontSize is valid Sp}`(forge: Forge) { + // Given + val fakeFontSizeSp = forge.aFloat(min = 1f, max = 500f) + val testData = setupTextLayoutMocks(forge) + whenever(testData.textLayoutResult.layoutInput.style.fontSize) doReturn + TextUnit(fakeFontSizeSp, TextUnitType.Sp) + + // When + val result = requireNotNull(testedSemanticsUtils.resolveTextLayoutInfo(mockSemanticsNode, mockInternalLogger)) + + // Then + assertThat(result.fontSize).isEqualTo(fakeFontSizeSp.toLong()) + } + @Test fun `M return backgroundInfo W resolveBackgroundInfo`( forge: Forge, 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 64dc0c2be4..e5f031c9e7 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 @@ -9,6 +9,7 @@ package com.datadog.android.sessionreplay.internal.recorder import android.app.Application import android.os.Handler import android.os.Looper +import android.view.View import android.view.Window import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting @@ -71,6 +72,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { private val internalLogger: InternalLogger private val uiHandler: Handler private val resourceResolver: ResourceResolver + private val windowFromDecorView: (View) -> Window? private var shouldRecord = false @Suppress("LongParameterList") @@ -224,6 +226,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { this.uiHandler = Handler(Looper.getMainLooper()) this.internalLogger = internalLogger + this.windowFromDecorView = { WindowReflectionUtils.getWindowFromDecorView(it, internalLogger) } } @VisibleForTesting @@ -240,7 +243,10 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { recordedDataQueueHandler: RecordedDataQueueHandler, resourceResolver: ResourceResolver, uiHandler: Handler, - internalLogger: InternalLogger + internalLogger: InternalLogger, + windowFromDecorView: (View) -> Window? = { + WindowReflectionUtils.getWindowFromDecorView(it, internalLogger) + } ) { this.appContext = appContext this.textAndInputPrivacy = textAndInputPrivacy @@ -253,6 +259,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { this.sessionReplayLifecycleCallback = sessionReplayLifecycleCallback this.resourceResolver = resourceResolver this.uiHandler = uiHandler + this.windowFromDecorView = windowFromDecorView this.internalLogger = internalLogger } @@ -275,7 +282,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { shouldRecord = true val windows = sessionReplayLifecycleCallback.getCurrentWindows() val decorViews = windowInspector.getGlobalWindowViews(internalLogger) - windowCallbackInterceptor.intercept(windows, appContext) + windowCallbackInterceptor.intercept(windows + resolveUntrackedWindows(decorViews, windows), appContext) viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy) } } @@ -292,7 +299,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { override fun onWindowsAdded(windows: List) { if (shouldRecord) { val decorViews = windowInspector.getGlobalWindowViews(internalLogger) - windowCallbackInterceptor.intercept(windows, appContext) + windowCallbackInterceptor.intercept(windows + resolveUntrackedWindows(decorViews, windows), appContext) viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy) } } @@ -305,4 +312,13 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy) } } + + // a window already open (e.g. a dialog) isn't reported through the Activity lifecycle + private fun resolveUntrackedWindows(decorViews: List, knownWindows: List): List { + return decorViews + .filterNot { it.width == 0 || it.height == 0 } + .mapNotNull { windowFromDecorView(it) } + .filterNot { it in knownWindows } + .distinct() + } } 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 44566280fb..cff054dd5c 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 @@ -31,8 +31,21 @@ internal class WindowCallbackInterceptor( ) { private val wrappedWindows: WeakHashMap = WeakHashMap() + // windows explicitly torn down via stopIntercepting(windows) (e.g. a paused Activity whose + // decorView is still globally attached) — RecorderWindowCallback's dynamic discovery must not + // re-wrap these on its own, or a subsequent intercept() on resume would double-wrap them and + // stopIntercepting() would only ever unwind the outer layer. + private val excludedWindows: WeakHashMap = WeakHashMap() + + // guards RecorderWindowCallback's deferred (posted) installation of callbacks on newly + // discovered dialog windows: without this, a dialog detected right before stopIntercepting() + // could still get wrapped after we've stopped, and would never be torn down afterwards. + private var isStopped = false + fun intercept(windows: List, appContext: Context) { + isStopped = false windows.forEach { window -> + excludedWindows.remove(window) wrapWindowCallback(window, appContext) wrappedWindows[window] = null } @@ -40,19 +53,30 @@ internal class WindowCallbackInterceptor( fun stopIntercepting(windows: List) { windows.forEach { + excludedWindows[it] = null unwrapWindowCallback(it) wrappedWindows.remove(it) } } fun stopIntercepting() { + isStopped = true wrappedWindows.entries.forEach { unwrapWindowCallback(it.key) } wrappedWindows.clear() + excludedWindows.clear() + } + + // a RecorderWindowCallback can discover and wrap windows on its own (e.g. dialogs, which + // aren't reported through the Activity lifecycle) — this lets it register those windows here + // so they get torn down by stopIntercepting() like any other tracked window. + internal fun trackWrappedWindow(window: Window) { + wrappedWindows[window] = null } private fun wrapWindowCallback(window: Window, appContext: Context) { + if (window.callback is RecorderWindowCallback) return val toWrap = window.callback ?: NoOpWindowCallback() window.callback = RecorderWindowCallback( appContext, @@ -64,7 +88,9 @@ internal class WindowCallbackInterceptor( internalLogger, textAndInputPrivacy, imagePrivacy, - touchPrivacyManager + touchPrivacyManager, + onWindowWrapped = { trackWrappedWindow(it) }, + shouldInstallCallbacks = { candidate -> !isStopped && !excludedWindows.containsKey(candidate) } ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtils.kt new file mode 100644 index 0000000000..77c9cf40c3 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtils.kt @@ -0,0 +1,60 @@ +/* + * 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 + +import android.annotation.SuppressLint +import android.view.View +import android.view.Window +import com.datadog.android.api.InternalLogger + +@SuppressLint("PrivateApi") // intentional: accessing mWindow via reflection to get the Window from a decor view +internal object WindowReflectionUtils { + + internal const val FAILED_TO_RETRIEVE_WINDOW_ERROR_MESSAGE = + "SR WindowReflectionUtils failed to retrieve the Window from the decor view via reflection" + + private const val WINDOW_FIELD_NAME = "mWindow" + + fun getWindowFromDecorView(view: View, internalLogger: InternalLogger): Window? { + return try { + var currentClass: Class<*>? = view.javaClass + var lastFieldMiss: NoSuchFieldException? = null + while (currentClass != null) { + try { + @Suppress("UnsafeThirdPartyFunctionCall") // exceptions caught by outer try-catch + return currentClass.getDeclaredField(WINDOW_FIELD_NAME) + .also { it.isAccessible = true } + .get(view) as? Window + } catch (e: NoSuchFieldException) { + lastFieldMiss = e + currentClass = currentClass.superclass + } + } + // every real decorView declares mWindow somewhere in its hierarchy, so exhausting it + // without finding the field means hidden-API enforcement is blocking access on this + // OS version/OEM (it surfaces as NoSuchFieldException, indistinguishable from "absent"). + lastFieldMiss?.let { logReflectionFailure(internalLogger, it) } + null + } catch (e: ReflectiveOperationException) { + logReflectionFailure(internalLogger, e) + null + } catch (e: SecurityException) { + logReflectionFailure(internalLogger, e) + null + } + } + + private fun logReflectionFailure(internalLogger: InternalLogger, throwable: Throwable) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.TELEMETRY, + { FAILED_TO_RETRIEVE_WINDOW_ERROR_MESSAGE }, + throwable, + true + ) + } +} 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 c6fe0a1f92..27cc32818e 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 @@ -9,6 +9,8 @@ package com.datadog.android.sessionreplay.internal.recorder.callback import android.content.Context import android.graphics.Point import android.view.MotionEvent +import android.view.View +import android.view.ViewTreeObserver import android.view.Window import androidx.annotation.MainThread import com.datadog.android.api.InternalLogger @@ -21,14 +23,16 @@ 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 +import com.datadog.android.sessionreplay.internal.recorder.WindowReflectionUtils import com.datadog.android.sessionreplay.internal.utils.RumContextProvider import com.datadog.android.sessionreplay.model.MobileSegment import java.util.LinkedList +import java.util.WeakHashMap import java.util.concurrent.TimeUnit @Suppress("TooGenericExceptionCaught") internal class RecorderWindowCallback( - appContext: Context, + private val appContext: Context, private val recordedDataQueueHandler: RecordedDataQueueHandler, internal val wrappedCallback: Window.Callback, private val timeProvider: TimeProvider, @@ -45,7 +49,12 @@ internal class RecorderWindowCallback( private val motionEventUtils: MotionEventUtils = MotionEventUtils, private val motionUpdateThresholdInNs: Long = MOTION_UPDATE_DELAY_THRESHOLD_NS, private val flushPositionBufferThresholdInNs: Long = FLUSH_BUFFER_THRESHOLD_NS, - private val windowInspector: WindowInspector = WindowInspector + private val windowInspector: WindowInspector = WindowInspector, + private val windowFromDecorView: ( + View + ) -> Window? = { WindowReflectionUtils.getWindowFromDecorView(it, internalLogger) }, + private val onWindowWrapped: (Window) -> Unit = {}, + internal val shouldInstallCallbacks: (Window) -> Boolean = { true } ) : FixedWindowCallback(wrappedCallback) { private val pixelsDensity = appContext.resources.displayMetrics.density internal val pointerInteractions: MutableList = LinkedList() @@ -59,7 +68,13 @@ internal class RecorderWindowCallback( override fun dispatchTouchEvent(event: MotionEvent?): Boolean { if (event != null) { if (event.action == MotionEvent.ACTION_DOWN) { - val touchLocation = Point(event.x.toInt(), event.y.toInt()) + // touch privacy override areas are screen-absolute (built from getLocationOnScreen()), + // so we must compare against raw/absolute coordinates rather than event.x/y, which are + // window-local and only match screen coordinates for windows positioned at the origin. + val touchLocation = Point( + motionEventUtils.getPointerAbsoluteX(event, 0).toInt(), + motionEventUtils.getPointerAbsoluteY(event, 0).toInt() + ) shouldRecordMotion = touchPrivacyManager.shouldRecordTouch(touchLocation) } @@ -102,6 +117,7 @@ internal class RecorderWindowCallback( textAndInputPrivacy = privacy, imagePrivacy = imagePrivacy ) + installCallbackOnNewWindows(rootViews) } super.onWindowFocusChanged(hasFocus) } @@ -110,6 +126,88 @@ internal class RecorderWindowCallback( // region Internal + private fun installCallbackOnNewWindows(rootViews: List) { + rootViews.forEach { decorView -> installCallbackOnWindow(decorView) } + } + + private fun installCallbackOnWindow(decorView: View) { + val window = windowFromDecorView(decorView) + if (window == null) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { + WINDOW_FROM_DECOR_VIEW_ERROR_MESSAGE_PREFIX + + decorView.javaClass.name + + WINDOW_FROM_DECOR_VIEW_ERROR_MESSAGE_SUFFIX + }, + onlyOnce = true + ) + return + } + // Zero-size windows (NavHost scaffolding, or a dialog window not yet laid out) would + // trigger spurious stopIntercepting() calls that drop frames, so we wait for the next + // layout pass instead of installing immediately. + if (decorView.width == 0 || decorView.height == 0) { + retryInstallAfterNextLayout(decorView, window) + return + } + if (window.callback !is RecorderWindowCallback) { + // Post so the dialog's own onWindowFocusChanged(true) fires first, + // ensuring the new callback starts in a steady recording state. + decorView.post { + // re-check: recording may have stopped, or another focus change may have + // already installed a callback, while this post was pending in the queue. + if (window.callback !is RecorderWindowCallback && shouldInstallCallbacks(window)) { + val toWrap = window.callback ?: NoOpWindowCallback() + window.callback = RecorderWindowCallback( + appContext = appContext, + recordedDataQueueHandler = recordedDataQueueHandler, + wrappedCallback = toWrap, + timeProvider = timeProvider, + rumContextProvider = rumContextProvider, + viewOnDrawInterceptor = viewOnDrawInterceptor, + internalLogger = internalLogger, + privacy = privacy, + imagePrivacy = imagePrivacy, + touchPrivacyManager = touchPrivacyManager, + windowInspector = windowInspector, + windowFromDecorView = windowFromDecorView, + onWindowWrapped = onWindowWrapped, + shouldInstallCallbacks = shouldInstallCallbacks + ) + onWindowWrapped(window) + } + } + } + } + + private fun retryInstallAfterNextLayout(decorView: View, window: Window) { + // dedup across every onWindowFocusChanged rescan (and every other window's RecorderWindowCallback + // instance): a permanently zero-size view (e.g. NavHost scaffolding) never fires the removal + // path below, so without this a listener would pile up on it forever. + val alreadyPending = pendingLayoutRetries.put(window, Unit) != null + if (alreadyPending) return + val viewTreeObserver = decorView.viewTreeObserver + if (viewTreeObserver == null || !viewTreeObserver.isAlive) { + pendingLayoutRetries.remove(window) + return + } + viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (decorView.width == 0 || decorView.height == 0) return + pendingLayoutRetries.remove(window) + val currentObserver = decorView.viewTreeObserver + if (currentObserver.isAlive) { + currentObserver.removeOnGlobalLayoutListener(this) + } + installCallbackOnWindow(decorView) + } + } + ) + } + @MainThread private fun handleEvent(event: MotionEvent) { when (event.action.and(MotionEvent.ACTION_MASK)) { @@ -203,6 +301,10 @@ internal class RecorderWindowCallback( // endregion companion object { + // shared across all RecorderWindowCallback instances (one per active window) so a decorView + // scanned by multiple windows' focus events still gets at most one pending retry listener. + private val pendingLayoutRetries: WeakHashMap = WeakHashMap() + private const val EVENT_CONSUMED: Boolean = true // every frame we collect the move event positions @@ -215,5 +317,9 @@ internal class RecorderWindowCallback( "RecorderWindowCallback: intercepted null motion event" internal const val FAIL_TO_PROCESS_MOTION_EVENT_ERROR_MESSAGE = "RecorderWindowCallback: wrapped callback failed to handle the motion event" + internal const val WINDOW_FROM_DECOR_VIEW_ERROR_MESSAGE_PREFIX = + "SR: failed to get Window from " + internal const val WINDOW_FROM_DECOR_VIEW_ERROR_MESSAGE_SUFFIX = + " via reflection — Compose dialog destination may not be recorded" } } 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 b4e218acb0..49d63081d8 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 @@ -108,6 +108,113 @@ internal class SessionReplayRecorderTest { ) } + @Test + fun `M also intercept an already-open untracked window W resumeRecorders`() { + // Given + val mockDialogWindow: Window = mock() + val mockDialogDecorView: View = mock { + whenever(it.width).thenReturn(800) + whenever(it.height).thenReturn(600) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(fakeActiveWindowsDecorViews + mockDialogDecorView) + testedSessionReplayRecorder = buildRecorderWith( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null } + ) + + // When + testedSessionReplayRecorder.resumeRecorders() + + // Then + verify(mockWindowCallbackInterceptor).intercept( + fakeActiveWindows + mockDialogWindow, + appContext.mockInstance + ) + } + + @Test + fun `M not retry an already-known window W resumeRecorders {resolves to a tracked window}`() { + // Given + val mockDecorView: View = mock { + whenever(it.width).thenReturn(800) + whenever(it.height).thenReturn(600) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(fakeActiveWindowsDecorViews + mockDecorView) + testedSessionReplayRecorder = buildRecorderWith( + windowFromDecorView = { view -> if (view == mockDecorView) fakeActiveWindows.first() else null } + ) + + // When + testedSessionReplayRecorder.resumeRecorders() + + // Then + verify(mockWindowCallbackInterceptor).intercept(fakeActiveWindows, appContext.mockInstance) + } + + @Test + fun `M not resolve a zero-size untracked window W resumeRecorders`() { + // Given + val mockDialogWindow: Window = mock() + val mockZeroSizeDecorView: View = mock() + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(fakeActiveWindowsDecorViews + mockZeroSizeDecorView) + testedSessionReplayRecorder = buildRecorderWith( + windowFromDecorView = { view -> if (view == mockZeroSizeDecorView) mockDialogWindow else null } + ) + + // When + testedSessionReplayRecorder.resumeRecorders() + + // Then + verify(mockWindowCallbackInterceptor).intercept(fakeActiveWindows, appContext.mockInstance) + } + + @Test + fun `M also intercept an already-open untracked window W onWindowsAdded{resumed}`(forge: Forge) { + // Given + val mockDialogWindow: Window = mock() + val mockDialogDecorView: View = mock { + whenever(it.width).thenReturn(800) + whenever(it.height).thenReturn(600) + } + testedSessionReplayRecorder = buildRecorderWith( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null } + ) + testedSessionReplayRecorder.resumeRecorders() + val fakeAddedWindows = forge.aList { mock() } + val fakeNewDecorViews = fakeAddedWindows.map { mock() } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(fakeNewDecorViews + mockDialogDecorView) + + // When + testedSessionReplayRecorder.onWindowsAdded(fakeAddedWindows) + + // Then + verify(mockWindowCallbackInterceptor).intercept( + fakeAddedWindows + mockDialogWindow, + appContext.mockInstance + ) + } + + private fun buildRecorderWith(windowFromDecorView: (View) -> Window?): SessionReplayRecorder { + return SessionReplayRecorder( + appContext = appContext.mockInstance, + textAndInputPrivacy = fakeTextAndInputPrivacy, + imagePrivacy = fakeImagePrivacy, + customOptionSelectorDetectors = mock(), + windowInspector = mockWindowInspector, + windowCallbackInterceptor = mockWindowCallbackInterceptor, + sessionReplayLifecycleCallback = mockLifecycleCallback, + viewOnDrawInterceptor = mockViewOnDrawInterceptor, + recordedDataQueueHandler = mockRecordedDataQueueHandler, + resourceResolver = mockResourceResolver, + uiHandler = mockUiHandler, + internalLogger = mockInternalLogger, + windowFromDecorView = windowFromDecorView + ) + } + @Test fun `M register the lifecycle callback W registerCallbacks`() { // When 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 4929914b59..b47ed2cd68 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 @@ -108,6 +108,22 @@ internal class WindowCallbackInterceptorTest { } } + @Test + fun `M not re-wrap an already-intercepted window W intercept() {called again for same window}`() { + // Given + val mockWindow: Window = mock() + testedInterceptor.intercept(listOf(mockWindow), mockActivity) + val captor = argumentCaptor() + verify(mockWindow).callback = captor.capture() + whenever(mockWindow.callback).thenReturn(captor.firstValue) + + // When + testedInterceptor.intercept(listOf(mockWindow), mockActivity) + + // Then + verify(mockWindow, times(1)).callback = any() + } + @Test fun `M register the RecorderWindowCallback W intercept{default callback is null}`() { // Given @@ -237,6 +253,92 @@ internal class WindowCallbackInterceptorTest { } } + @Test + fun `M forbid new callback installs W stopIntercepting()`() { + // Given + testedInterceptor.intercept(fakeWindowsList, mockActivity) + val captor = argumentCaptor() + verify(fakeWindowsList.first()).callback = captor.capture() + val installedCallback = captor.firstValue as RecorderWindowCallback + + // When + testedInterceptor.stopIntercepting() + + // Then + assertThat(installedCallback.shouldInstallCallbacks(fakeWindowsList.first())).isFalse() + } + + @Test + fun `M allow new callback installs again W intercept() {after a previous stopIntercepting()}`() { + // Given + testedInterceptor.intercept(fakeWindowsList, mockActivity) + testedInterceptor.stopIntercepting() + + // When + testedInterceptor.intercept(fakeWindowsList, mockActivity) + + // Then + val captor = argumentCaptor() + verify(fakeWindowsList.first(), times(2)).callback = captor.capture() + val installedCallback = captor.lastValue as RecorderWindowCallback + assertThat(installedCallback.shouldInstallCallbacks(fakeWindowsList.first())).isTrue() + } + + @Test + fun `M exclude window from dynamic install W stopIntercepting(windows) {single window torn down}`() { + // Given — mimics an Activity window being paused while another (e.g. a dialog) stays recording + val pausedWindow: Window = mock() + val stillActiveWindow: Window = mock() + testedInterceptor.intercept(listOf(pausedWindow, stillActiveWindow), mockActivity) + val captor = argumentCaptor() + verify(stillActiveWindow).callback = captor.capture() + val installedCallback = captor.firstValue as RecorderWindowCallback + + // When + testedInterceptor.stopIntercepting(listOf(pausedWindow)) + + // Then + assertThat(installedCallback.shouldInstallCallbacks(pausedWindow)).isFalse() + assertThat(installedCallback.shouldInstallCallbacks(stillActiveWindow)).isTrue() + } + + @Test + fun `M allow dynamic install again W intercept() {window re-added after stopIntercepting(windows)}`() { + // Given + val pausedWindow: Window = mock() + val stillActiveWindow: Window = mock() + testedInterceptor.intercept(listOf(pausedWindow, stillActiveWindow), mockActivity) + val captor = argumentCaptor() + verify(stillActiveWindow).callback = captor.capture() + val installedCallback = captor.firstValue as RecorderWindowCallback + testedInterceptor.stopIntercepting(listOf(pausedWindow)) + + // When + testedInterceptor.intercept(listOf(pausedWindow), mockActivity) + + // Then + assertThat(installedCallback.shouldInstallCallbacks(pausedWindow)).isTrue() + } + + @Test + fun `M unwrap dynamically tracked window W trackWrappedWindow() then stopIntercepting()`() { + // Given + val mockDefaultCallback: Window.Callback = mock() + val mockDynamicallyWrappedCallback: RecorderWindowCallback = mock { + whenever(it.wrappedCallback).thenReturn(mockDefaultCallback) + } + val mockDialogWindow: Window = mock { + whenever(it.callback).thenReturn(mockDynamicallyWrappedCallback) + } + + // When + testedInterceptor.trackWrappedWindow(mockDialogWindow) + testedInterceptor.stopIntercepting() + + // Then + verify(mockDialogWindow).callback = mockDefaultCallback + } + @Test fun `M do nothing W stopIntercepting(windows){window callback was not wrapped}`() { // When diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtilsTest.kt new file mode 100644 index 0000000000..f59a21d101 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtilsTest.kt @@ -0,0 +1,64 @@ +/* + * 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 + +import android.view.View +import android.view.Window +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.utils.verifyLog +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +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.mock +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class WindowReflectionUtilsTest { + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Test + fun `M return null and log to telemetry W getWindowFromDecorView {view has no mWindow field}`() { + assertThat(WindowReflectionUtils.getWindowFromDecorView(mock(), mockInternalLogger)).isNull() + mockInternalLogger.verifyLog( + InternalLogger.Level.ERROR, + InternalLogger.Target.TELEMETRY, + WindowReflectionUtils.FAILED_TO_RETRIEVE_WINDOW_ERROR_MESSAGE, + NoSuchFieldException::class.java, + onlyOnce = true + ) + } + + @Test + fun `M return Window W getWindowFromDecorView {view class has mWindow field}`() { + // Given — mimics DecorView's private mWindow field + val fakeWindow = mock() + val fakeDecorLike = object : View(mock()) { + @Suppress("unused") + private val mWindow: Window = fakeWindow + } + + // When + Then + assertThat(WindowReflectionUtils.getWindowFromDecorView(fakeDecorLike, mockInternalLogger)) + .isSameAs(fakeWindow) + verifyNoInteractions(mockInternalLogger) + } +} 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 390d6e8230..68cca90f32 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 @@ -11,6 +11,7 @@ import android.content.res.Resources import android.util.DisplayMetrics import android.view.MotionEvent import android.view.View +import android.view.ViewTreeObserver import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.internal.time.TimeProvider @@ -47,6 +48,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -233,6 +235,33 @@ internal class RecorderWindowCallbackTest { ) } + @Test + fun `M use absolute coordinates for touch privacy check W onTouchEvent() { ActionDown }`( + forge: Forge + ) { + // Given — the privacy override areas are screen-absolute (built from + // getLocationOnScreen()), so the check must use raw/absolute pointer coordinates + // rather than event.x/y, which are window-local and only match screen coordinates + // for windows positioned at the origin (e.g. not an offset dialog window). + val mockEvent: MotionEvent = mock { + whenever(it.action).thenReturn(MotionEvent.ACTION_DOWN) + } + whenever(mockEventUtils.getPointerAbsoluteX(mockEvent, 0)) + .thenReturn(forge.aFloat(min = 0f, max = 500f)) + whenever(mockEventUtils.getPointerAbsoluteY(mockEvent, 0)) + .thenReturn(forge.aFloat(min = 0f, max = 500f)) + + // When + testedWindowCallback.dispatchTouchEvent(mockEvent) + + // Then + verify(mockEventUtils).getPointerAbsoluteX(mockEvent, 0) + verify(mockEventUtils).getPointerAbsoluteY(mockEvent, 0) + verify(mockEvent, never()).x + verify(mockEvent, never()).y + verify(mockTouchPrivacyManager).shouldRecordTouch(any()) + } + @Test fun `M update the positions W onTouchEvent() { ActionDown }`(forge: Forge) { // Given @@ -459,6 +488,251 @@ internal class RecorderWindowCallbackTest { } } + @Test + fun `M install RecorderWindowCallback on new dialog window W window focus changed`(forge: Forge) { + // Given + val mockDialogWindow = mock() + val mockDialogDecorView = mock().also { + whenever(it.width).thenReturn(800) + whenever(it.height).thenReturn(600) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mockDialogDecorView)) + testedWindowCallback = buildCallbackFor( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null } + ) + + // When + testedWindowCallback.onWindowFocusChanged(forge.aBool()) + + // Then + // Callback installation is posted so the dialog's first focus event passes through + // the original callback. Capture and run the Runnable to simulate the post. + argumentCaptor { + verify(mockDialogDecorView).post(capture()) + firstValue.run() + } + verify(mockDialogWindow).callback = any() + } + + @Test + fun `M skip install W window focus changed {recording stopped before post ran}`(forge: Forge) { + // Given + val mockDialogWindow = mock() + val mockDialogDecorView = mock().also { + whenever(it.width).thenReturn(800) + whenever(it.height).thenReturn(600) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mockDialogDecorView)) + testedWindowCallback = buildCallbackFor( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null }, + shouldInstallCallbacks = { false } + ) + + // When + testedWindowCallback.onWindowFocusChanged(forge.aBool()) + argumentCaptor { + verify(mockDialogDecorView).post(capture()) + firstValue.run() + } + + // Then + verify(mockDialogWindow, never()).callback = any() + } + + @Test + fun `M notify onWindowWrapped W window focus changed {new dialog window installed}`(forge: Forge) { + // Given + val mockDialogWindow = mock() + val mockDialogDecorView = mock().also { + whenever(it.width).thenReturn(800) + whenever(it.height).thenReturn(600) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mockDialogDecorView)) + val wrappedWindows = mutableListOf() + testedWindowCallback = buildCallbackFor( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null }, + onWindowWrapped = { wrappedWindows.add(it) } + ) + + // When + testedWindowCallback.onWindowFocusChanged(forge.aBool()) + argumentCaptor { + verify(mockDialogDecorView).post(capture()) + firstValue.run() + } + + // Then + assertThat(wrappedWindows).containsExactly(mockDialogWindow) + } + + @Test + fun `M not replace existing RecorderWindowCallback W window focus changed`(forge: Forge) { + // Given + val mockDialogWindow = mock().also { + whenever(it.callback).thenReturn(mock()) + } + val mockDialogDecorView = mock().also { + whenever(it.width).thenReturn(800) + whenever(it.height).thenReturn(600) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mockDialogDecorView)) + testedWindowCallback = buildCallbackFor( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null } + ) + + // When + testedWindowCallback.onWindowFocusChanged(forge.aBool()) + + // Then + verify(mockDialogDecorView, never()).post(any()) + verify(mockDialogWindow, never()).callback = any() + } + + @Test + fun `M defer install W window focus changed {decorView has zero dimensions}`() { + // Given + val mockDialogWindow = mock() + val mockViewTreeObserver = mock().also { + whenever(it.isAlive).thenReturn(true) + } + val mockDialogDecorView = mock().also { + // width/height default to 0 + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mockDialogDecorView)) + testedWindowCallback = buildCallbackFor( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null } + ) + + // When + testedWindowCallback.onWindowFocusChanged(false) + + // Then + verify(mockViewTreeObserver).addOnGlobalLayoutListener(any()) + verify(mockDialogWindow, never()).callback = any() + } + + @Test + fun `M install RecorderWindowCallback W global layout fires {decorView was zero-size}`(forge: Forge) { + // Given + val mockDialogWindow = mock() + val mockViewTreeObserver = mock().also { + whenever(it.isAlive).thenReturn(true) + } + val mockDialogDecorView = mock().also { + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mockDialogDecorView)) + testedWindowCallback = buildCallbackFor( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null } + ) + testedWindowCallback.onWindowFocusChanged(forge.aBool()) + val layoutListener = argumentCaptor().let { + verify(mockViewTreeObserver).addOnGlobalLayoutListener(it.capture()) + it.firstValue + } + + // When + // simulate the pending layout pass resolving the decorView's size + whenever(mockDialogDecorView.width).thenReturn(800) + whenever(mockDialogDecorView.height).thenReturn(600) + layoutListener.onGlobalLayout() + + // Then + verify(mockViewTreeObserver).removeOnGlobalLayoutListener(layoutListener) + argumentCaptor { + verify(mockDialogDecorView).post(capture()) + firstValue.run() + } + verify(mockDialogWindow).callback = any() + } + + @Test + fun `M not install RecorderWindowCallback W global layout fires {still zero-size}`(forge: Forge) { + // Given + val mockDialogWindow = mock() + val mockViewTreeObserver = mock().also { + whenever(it.isAlive).thenReturn(true) + } + val mockDialogDecorView = mock().also { + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mockDialogDecorView)) + testedWindowCallback = buildCallbackFor( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null } + ) + testedWindowCallback.onWindowFocusChanged(forge.aBool()) + val layoutListener = argumentCaptor().let { + verify(mockViewTreeObserver).addOnGlobalLayoutListener(it.capture()) + it.firstValue + } + + // When + // decorView is still not laid out (width/height remain 0) + layoutListener.onGlobalLayout() + + // Then + verify(mockViewTreeObserver, never()).removeOnGlobalLayoutListener(any()) + verify(mockDialogDecorView, never()).post(any()) + } + + @Test + fun `M register only one listener W window focus changed repeatedly {decorView permanently zero-size}`( + forge: Forge + ) { + // Given — mimics NavHost scaffolding: never gets laid out to a nonzero size + val mockDialogWindow = mock() + val mockViewTreeObserver = mock().also { + whenever(it.isAlive).thenReturn(true) + } + val mockDialogDecorView = mock().also { + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mockDialogDecorView)) + testedWindowCallback = buildCallbackFor( + windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null } + ) + + // When — several unrelated focus changes rescan the same permanently zero-size view + repeat(5) { testedWindowCallback.onWindowFocusChanged(forge.aBool()) } + + // Then + verify(mockViewTreeObserver, times(1)).addOnGlobalLayoutListener(any()) + } + + @Test + fun `M log warning and skip W window focus changed {windowFromDecorView returns null}`() { + // Given + val mockDialogDecorView = mock().also { + whenever(it.width).thenReturn(800) + whenever(it.height).thenReturn(600) + } + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mockDialogDecorView)) + testedWindowCallback = buildCallbackFor(windowFromDecorView = { null }) + + // When + testedWindowCallback.onWindowFocusChanged(false) + + // Then + mockInternalLogger.verifyLog( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + RecorderWindowCallback.WINDOW_FROM_DECOR_VIEW_ERROR_MESSAGE_PREFIX + + mockDialogDecorView.javaClass.name + + RecorderWindowCallback.WINDOW_FROM_DECOR_VIEW_ERROR_MESSAGE_SUFFIX, + onlyOnce = true + ) + } + @Test fun `M do nothing W window focus changed {decorViews could not be fetched}`(forge: Forge) { // Given @@ -577,6 +851,31 @@ internal class RecorderWindowCallbackTest { // region Internal + private fun buildCallbackFor( + windowFromDecorView: (View) -> Window? = { null }, + onWindowWrapped: (Window) -> Unit = {}, + shouldInstallCallbacks: (Window) -> Boolean = { true } + ) = RecorderWindowCallback( + appContext = mockContext, + recordedDataQueueHandler = mockRecordedDataQueueHandler, + wrappedCallback = mockWrappedCallback, + timeProvider = mockTimeProvider, + rumContextProvider = mockRumContextProvider, + viewOnDrawInterceptor = mockViewOnDrawInterceptor, + internalLogger = mockInternalLogger, + privacy = fakeTextAndInputPrivacy, + imagePrivacy = ImagePrivacy.MASK_NONE, + touchPrivacyManager = mockTouchPrivacyManager, + copyEvent = { it }, + motionEventUtils = mockEventUtils, + motionUpdateThresholdInNs = TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS, + flushPositionBufferThresholdInNs = TEST_FLUSH_BUFFER_THRESHOLD_NS, + windowInspector = mockWindowInspector, + windowFromDecorView = windowFromDecorView, + onWindowWrapped = onWindowWrapped, + shouldInstallCallbacks = shouldInstallCallbacks + ) + private fun Forge.touchRecords( eventType: MobileSegment.PointerEventType ): List { diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/FineGrainedMaskingSample.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/FineGrainedMaskingSample.kt index 816560696f..a6907b2b1a 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/FineGrainedMaskingSample.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/FineGrainedMaskingSample.kt @@ -9,6 +9,7 @@ package com.datadog.android.sample.compose import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -140,9 +141,11 @@ private fun TouchPrivacySample() { SampleItem( modifier = Modifier.wrapContentSize().weight(1f) .sessionReplayTouchPrivacy(touchPrivacy = TouchPrivacy.SHOW) - .clickable { - showClickTimes++ - }, + // indication = null: prevents PlatformRipple crash during AnimatedContent transitions on older Compose. + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { showClickTimes++ }, title = "Touch Privacy: Show" ) { Text( @@ -156,9 +159,10 @@ private fun TouchPrivacySample() { SampleItem( modifier = Modifier.wrapContentSize().weight(1f) .sessionReplayTouchPrivacy(touchPrivacy = TouchPrivacy.HIDE) - .clickable { - hideClickTimes++ - }, + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { hideClickTimes++ }, title = "Touch Privacy: Hide" ) { Text( diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/NestedDialogsSample.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/NestedDialogsSample.kt new file mode 100644 index 0000000000..45b868061e --- /dev/null +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/NestedDialogsSample.kt @@ -0,0 +1,73 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sample.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog +import androidx.navigation.compose.rememberNavController + +/** + * Reproduces a Compose NavHost `dialog {}` destination opened on top of another `dialog {}` + * destination (e.g. a wizard flow across multiple dialogs, or a confirmation sheet opened + * from within a bottom sheet). Each dialog destination opens its own Android [android.view.Window], + * which Session Replay must discover and record independently of the Activity window. + */ +@Composable +internal fun NestedDialogsSample() { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = ROUTE_BASE) { + composable(ROUTE_BASE) { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text( + "This screen opens a dialog {} NavHost destination, which itself opens " + + "a second dialog {} destination on top of it." + ) + Button(onClick = { navController.navigate(ROUTE_FIRST_DIALOG) }) { + Text("Open first dialog") + } + } + } + dialog(ROUTE_FIRST_DIALOG) { + Column(modifier = Modifier.background(Color.Cyan).padding(24.dp)) { + Text("First dialog destination") + Button(onClick = { navController.navigate(ROUTE_SECOND_DIALOG) }) { + Text("Open second dialog") + } + } + } + dialog(ROUTE_SECOND_DIALOG) { + Column(modifier = Modifier.background(Color.Yellow).padding(24.dp)) { + Text("Second dialog destination — this must be recorded by Session Replay too") + Button(onClick = { navController.popBackStack() }) { + Text("Close") + } + } + } + } +} + +private const val ROUTE_BASE = "nested_dialogs_base" +private const val ROUTE_FIRST_DIALOG = "nested_dialogs_first" +private const val ROUTE_SECOND_DIALOG = "nested_dialogs_second" + +@Composable +@Preview(showBackground = true) +internal fun PreviewNestedDialogsSample() { + NestedDialogsSample() +} diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt index 2a02677231..15bf839f47 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/SampleSelectionScreen.kt @@ -36,8 +36,23 @@ internal fun SampleSelectionScreen( onTabsClicked: () -> Unit, onInteropViewClicked: () -> Unit, onNav3Clicked: () -> Unit, - onBackgroundClicked: () -> Unit + onBackgroundClicked: () -> Unit, + onNestedDialogsClicked: () -> Unit ) { + val samples = listOf( + "Typography Sample" to onTypographyClicked, + "Image Sample" to onImageClicked, + "Input Sample" to onInputClicked, + "Toggle Buttons Sample" to onToggleClicked, + "Tabs Sample" to onTabsClicked, + "Selectors Sample" to onSelectorsClicked, + "Fine Grained Masking Privacy Sample" to onFgmClicked, + "Legacy Sample" to onLegacyClicked, + "InteropView" to onInteropViewClicked, + "Navigation 3" to onNav3Clicked, + "Backgrounds" to onBackgroundClicked, + "Nested Dialogs Sample" to onNestedDialogsClicked + ) Column( modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally @@ -47,50 +62,9 @@ internal fun SampleSelectionScreen( text = "Jetpack Compose Sample", style = MaterialTheme.typography.h6 ) - StyledButton( - text = "Typography Sample", - onClick = onTypographyClicked - ) - StyledButton( - text = "Image Sample", - onClick = onImageClicked - ) - StyledButton( - text = "Input Sample", - onClick = onInputClicked - ) - StyledButton( - text = "Toggle Buttons Sample", - onClick = onToggleClicked - ) - StyledButton( - text = "Tabs Sample", - onClick = onTabsClicked - ) - StyledButton( - text = "Selectors Sample", - onClick = onSelectorsClicked - ) - StyledButton( - text = "Fine Grained Masking Privacy Sample", - onClick = onFgmClicked - ) - StyledButton( - text = "Legacy Sample", - onClick = onLegacyClicked - ) - StyledButton( - text = "InteropView", - onClick = onInteropViewClicked - ) - StyledButton( - text = "Navigation 3", - onClick = onNav3Clicked - ) - StyledButton( - text = "Backgrounds", - onClick = onBackgroundClicked - ) + samples.forEach { (text, onClick) -> + StyledButton(text = text, onClick = onClick) + } } } @@ -145,6 +119,9 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle }, onBackgroundClicked = { navController.navigate(SampleScreen.Background.navigationRoute) + }, + onNestedDialogsClicked = { + navController.navigate(SampleScreen.NestedDialogs.navigationRoute) } ) } @@ -185,6 +162,10 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle BackgroundSample() } + composable(SampleScreen.NestedDialogs.navigationRoute) { + NestedDialogsSample() + } + activity(SampleScreen.Legacy.navigationRoute) { activityClass = LegacyComposeActivity::class } @@ -210,6 +191,7 @@ internal sealed class SampleScreen( object InteropView : SampleScreen("$COMPOSE_ROOT/interop_view") object Navigation3 : SampleScreen("$COMPOSE_ROOT/nav3") object Background : SampleScreen("$COMPOSE_ROOT/background") + object NestedDialogs : SampleScreen("$COMPOSE_ROOT/nested_dialogs") companion object { private const val COMPOSE_ROOT = "compose" @@ -242,6 +224,8 @@ private fun PreviewSampleSelectionScreen() { onNav3Clicked = { }, onBackgroundClicked = { + }, + onNestedDialogsClicked = { } ) }