From a51b2751ca61c16e09809fa4d444693e62f9e409 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:35:40 +0300 Subject: [PATCH 1/6] RUM-16379: Guard against NaN font size in Compose text wireframes --- .../compose/internal/utils/SemanticsUtils.kt | 4 ++- .../internal/utils/SemanticsUtilsTest.kt | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) 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..0bea3ead74 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 @@ -384,7 +384,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 +476,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, From 25f427022c8e77504376656edf3e438d4c19ec6c Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:35:59 +0300 Subject: [PATCH 2/6] RUM-16379: Fix FGM screen in sample application --- .../sample/compose/FineGrainedMaskingSample.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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( From 8ad82d535894b4b370d089a0a2e23666c42f54bd Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:24:52 +0300 Subject: [PATCH 3/6] RUM-16379: Fix recording dialog opening dialog in sr --- detekt_custom_safe_calls_third_party.yml | 1 + .../recorder/WindowReflectionUtils.kt | 38 ++++++ .../callback/RecorderWindowCallback.kt | 56 ++++++++- .../recorder/WindowReflectionUtilsTest.kt | 48 ++++++++ .../callback/RecorderWindowCallbackTest.kt | 113 ++++++++++++++++++ 5 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtils.kt create mode 100644 features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtilsTest.kt diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index e8540d875e..35989d5e19 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -410,6 +410,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/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..7a3ca61a76 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtils.kt @@ -0,0 +1,38 @@ +/* + * 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 + +@SuppressLint("PrivateApi") // intentional: accessing mWindow via reflection to get the Window from a decor view +internal object WindowReflectionUtils { + + private const val WINDOW_FIELD_NAME = "mWindow" + + fun getWindowFromDecorView(view: View): Window? { + return try { + var currentClass: Class<*>? = view.javaClass + 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 (_: NoSuchFieldException) { + currentClass = currentClass.superclass + } + } + null + } catch (_: ReflectiveOperationException) { + null + } catch (_: SecurityException) { + null + } + } +} 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..63a92db9b6 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,7 @@ 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.Window import androidx.annotation.MainThread import com.datadog.android.api.InternalLogger @@ -21,6 +22,7 @@ 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 @@ -45,8 +47,10 @@ 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) } ) : FixedWindowCallback(wrappedCallback) { + private val appContext: Context = appContext private val pixelsDensity = appContext.resources.displayMetrics.density internal val pointerInteractions: MutableList = LinkedList() private var lastOnMoveUpdateTimeInNs: Long = 0L @@ -102,6 +106,7 @@ internal class RecorderWindowCallback( textAndInputPrivacy = privacy, imagePrivacy = imagePrivacy ) + installCallbackOnNewWindows(rootViews) } super.onWindowFocusChanged(hasFocus) } @@ -110,6 +115,51 @@ internal class RecorderWindowCallback( // region Internal + private fun installCallbackOnNewWindows(rootViews: List) { + rootViews.forEach { decorView -> + // Skip zero-size windows (NavHost scaffolding) — installing on them causes spurious + // stopIntercepting() calls that drop frames on every navigation event. + if (decorView.width == 0 || decorView.height == 0) return@forEach + 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@forEach + } + 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 { + if (window.callback !is RecorderWindowCallback) { + 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 + ) + } + } + } + } + } + @MainThread private fun handleEvent(event: MotionEvent) { when (event.action.and(MotionEvent.ACTION_MASK)) { @@ -215,5 +265,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/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..8480a6a574 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowReflectionUtilsTest.kt @@ -0,0 +1,48 @@ +/* + * 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.sessionreplay.forge.ForgeConfigurator +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.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class WindowReflectionUtilsTest { + + @Test + fun `M return null W getWindowFromDecorView {view has no mWindow field}`() { + assertThat(WindowReflectionUtils.getWindowFromDecorView(mock())).isNull() + } + + @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)).isSameAs(fakeWindow) + } +} 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..9471f9e7fd 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 @@ -47,6 +47,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 @@ -459,6 +460,97 @@ 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 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 skip zero-size window W window focus changed {decorView has zero dimensions}`() { + // Given + val mockDialogWindow = mock() + whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger)) + .thenReturn(listOf(mock())) // mock View defaults to width=0, height=0 + testedWindowCallback = buildCallbackFor(windowFromDecorView = { mockDialogWindow }) + + // When + testedWindowCallback.onWindowFocusChanged(false) + + // Then + verify(mockDialogWindow, never()).callback = 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 +669,27 @@ internal class RecorderWindowCallbackTest { // region Internal + private fun buildCallbackFor( + windowFromDecorView: (View) -> Window? = { null } + ) = 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 + ) + private fun Forge.touchRecords( eventType: MobileSegment.PointerEventType ): List { From 0b0bcf3af51fb6fb0b3d1898bcc4bdbe13d6fbd0 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:09:24 +0300 Subject: [PATCH 4/6] RUM-16379: Unwrap dynamically-installed dialog window callbacks on stop --- .../compose/internal/utils/SemanticsUtils.kt | 5 +- .../recorder/WindowCallbackInterceptor.kt | 18 +++- .../callback/RecorderWindowCallback.kt | 26 ++++-- .../recorder/WindowCallbackInterceptorTest.kt | 50 +++++++++++ .../callback/RecorderWindowCallbackTest.kt | 88 ++++++++++++++++++- 5 files changed, 176 insertions(+), 11 deletions(-) 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 0bea3ead74..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 { 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..c7c0b6dac1 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,7 +31,13 @@ internal class WindowCallbackInterceptor( ) { private val wrappedWindows: 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 -> wrapWindowCallback(window, appContext) wrappedWindows[window] = null @@ -46,12 +52,20 @@ internal class WindowCallbackInterceptor( } fun stopIntercepting() { + isStopped = true wrappedWindows.entries.forEach { unwrapWindowCallback(it.key) } wrappedWindows.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) { val toWrap = window.callback ?: NoOpWindowCallback() window.callback = RecorderWindowCallback( @@ -64,7 +78,9 @@ internal class WindowCallbackInterceptor( internalLogger, textAndInputPrivacy, imagePrivacy, - touchPrivacyManager + touchPrivacyManager, + onWindowWrapped = { trackWrappedWindow(it) }, + shouldInstallCallbacks = { !isStopped } ) } 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 63a92db9b6..ba42a006d3 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 @@ -30,7 +30,7 @@ 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, @@ -48,9 +48,10 @@ internal class RecorderWindowCallback( private val motionUpdateThresholdInNs: Long = MOTION_UPDATE_DELAY_THRESHOLD_NS, private val flushPositionBufferThresholdInNs: Long = FLUSH_BUFFER_THRESHOLD_NS, private val windowInspector: WindowInspector = WindowInspector, - private val windowFromDecorView: (View) -> Window? = { WindowReflectionUtils.getWindowFromDecorView(it) } + private val windowFromDecorView: (View) -> Window? = { WindowReflectionUtils.getWindowFromDecorView(it) }, + private val onWindowWrapped: (Window) -> Unit = {}, + internal val shouldInstallCallbacks: () -> Boolean = { true } ) : FixedWindowCallback(wrappedCallback) { - private val appContext: Context = appContext private val pixelsDensity = appContext.resources.displayMetrics.density internal val pointerInteractions: MutableList = LinkedList() private var lastOnMoveUpdateTimeInNs: Long = 0L @@ -63,7 +64,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) } @@ -138,7 +145,9 @@ internal class RecorderWindowCallback( // Post so the dialog's own onWindowFocusChanged(true) fires first, // ensuring the new callback starts in a steady recording state. decorView.post { - if (window.callback !is RecorderWindowCallback) { + // 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()) { val toWrap = window.callback ?: NoOpWindowCallback() window.callback = RecorderWindowCallback( appContext = appContext, @@ -152,8 +161,11 @@ internal class RecorderWindowCallback( imagePrivacy = imagePrivacy, touchPrivacyManager = touchPrivacyManager, windowInspector = windowInspector, - windowFromDecorView = windowFromDecorView + windowFromDecorView = windowFromDecorView, + onWindowWrapped = onWindowWrapped, + shouldInstallCallbacks = shouldInstallCallbacks ) + onWindowWrapped(window) } } } @@ -268,6 +280,6 @@ internal class RecorderWindowCallback( 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" + " 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/internal/recorder/WindowCallbackInterceptorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptorTest.kt index 4929914b59..12bc631d8c 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 @@ -237,6 +237,56 @@ 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()).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()).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/callback/RecorderWindowCallbackTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt index 9471f9e7fd..0172fa949f 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 @@ -234,6 +234,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 @@ -487,6 +514,59 @@ internal class RecorderWindowCallbackTest { 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 @@ -670,7 +750,9 @@ internal class RecorderWindowCallbackTest { // region Internal private fun buildCallbackFor( - windowFromDecorView: (View) -> Window? = { null } + windowFromDecorView: (View) -> Window? = { null }, + onWindowWrapped: (Window) -> Unit = {}, + shouldInstallCallbacks: () -> Boolean = { true } ) = RecorderWindowCallback( appContext = mockContext, recordedDataQueueHandler = mockRecordedDataQueueHandler, @@ -687,7 +769,9 @@ internal class RecorderWindowCallbackTest { motionUpdateThresholdInNs = TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS, flushPositionBufferThresholdInNs = TEST_FLUSH_BUFFER_THRESHOLD_NS, windowInspector = mockWindowInspector, - windowFromDecorView = windowFromDecorView + windowFromDecorView = windowFromDecorView, + onWindowWrapped = onWindowWrapped, + shouldInstallCallbacks = shouldInstallCallbacks ) private fun Forge.touchRecords( From 19641270bc1880a68757d31fa3188d35a0352dd9 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:52:25 +0300 Subject: [PATCH 5/6] RUMS-16379: Add a screen to test nested dialog recording --- .../sample/compose/NestedDialogsSample.kt | 73 ++++++++++++++++++ .../sample/compose/SampleSelectionScreen.kt | 74 ++++++++----------- 2 files changed, 102 insertions(+), 45 deletions(-) create mode 100644 sample/kotlin/src/main/kotlin/com/datadog/android/sample/compose/NestedDialogsSample.kt 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 = { } ) } From cb5fc57a8a02208c20d2e6688a6d1e676fb9621b Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:39:56 +0300 Subject: [PATCH 6/6] RUM-16379: PR review fixes --- detekt_custom_safe_calls_android.yml | 2 + detekt_custom_safe_calls_third_party.yml | 1 + .../recorder/SessionReplayRecorder.kt | 22 ++- .../recorder/WindowCallbackInterceptor.kt | 12 +- .../recorder/WindowReflectionUtils.kt | 30 +++- .../callback/RecorderWindowCallback.kt | 130 ++++++++++++------ .../SessionReplayRecorderTest.kt | 107 ++++++++++++++ .../recorder/WindowCallbackInterceptorTest.kt | 56 +++++++- .../recorder/WindowReflectionUtilsTest.kt | 22 ++- .../callback/RecorderWindowCallbackTest.kt | 110 ++++++++++++++- 10 files changed, 430 insertions(+), 62 deletions(-) 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 35989d5e19..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?)" 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 c7c0b6dac1..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,6 +31,12 @@ 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. @@ -39,6 +45,7 @@ internal class WindowCallbackInterceptor( fun intercept(windows: List, appContext: Context) { isStopped = false windows.forEach { window -> + excludedWindows.remove(window) wrapWindowCallback(window, appContext) wrappedWindows[window] = null } @@ -46,6 +53,7 @@ internal class WindowCallbackInterceptor( fun stopIntercepting(windows: List) { windows.forEach { + excludedWindows[it] = null unwrapWindowCallback(it) wrappedWindows.remove(it) } @@ -57,6 +65,7 @@ internal class WindowCallbackInterceptor( unwrapWindowCallback(it.key) } wrappedWindows.clear() + excludedWindows.clear() } // a RecorderWindowCallback can discover and wrap windows on its own (e.g. dialogs, which @@ -67,6 +76,7 @@ internal class WindowCallbackInterceptor( } private fun wrapWindowCallback(window: Window, appContext: Context) { + if (window.callback is RecorderWindowCallback) return val toWrap = window.callback ?: NoOpWindowCallback() window.callback = RecorderWindowCallback( appContext, @@ -80,7 +90,7 @@ internal class WindowCallbackInterceptor( imagePrivacy, touchPrivacyManager, onWindowWrapped = { trackWrappedWindow(it) }, - shouldInstallCallbacks = { !isStopped } + 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 index 7a3ca61a76..77c9cf40c3 100644 --- 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 @@ -9,30 +9,52 @@ 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): Window? { + 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 (_: NoSuchFieldException) { + } 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 (_: ReflectiveOperationException) { + } catch (e: ReflectiveOperationException) { + logReflectionFailure(internalLogger, e) null - } catch (_: SecurityException) { + } 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 ba42a006d3..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 @@ -10,6 +10,7 @@ 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 @@ -26,6 +27,7 @@ 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") @@ -48,9 +50,11 @@ internal class RecorderWindowCallback( private val motionUpdateThresholdInNs: Long = MOTION_UPDATE_DELAY_THRESHOLD_NS, private val flushPositionBufferThresholdInNs: Long = FLUSH_BUFFER_THRESHOLD_NS, private val windowInspector: WindowInspector = WindowInspector, - private val windowFromDecorView: (View) -> Window? = { WindowReflectionUtils.getWindowFromDecorView(it) }, + private val windowFromDecorView: ( + View + ) -> Window? = { WindowReflectionUtils.getWindowFromDecorView(it, internalLogger) }, private val onWindowWrapped: (Window) -> Unit = {}, - internal val shouldInstallCallbacks: () -> Boolean = { true } + internal val shouldInstallCallbacks: (Window) -> Boolean = { true } ) : FixedWindowCallback(wrappedCallback) { private val pixelsDensity = appContext.resources.displayMetrics.density internal val pointerInteractions: MutableList = LinkedList() @@ -123,53 +127,85 @@ internal class RecorderWindowCallback( // region Internal private fun installCallbackOnNewWindows(rootViews: List) { - rootViews.forEach { decorView -> - // Skip zero-size windows (NavHost scaffolding) — installing on them causes spurious - // stopIntercepting() calls that drop frames on every navigation event. - if (decorView.width == 0 || decorView.height == 0) return@forEach - 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@forEach + 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) + } } - 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()) { - 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 @@ -265,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 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 12bc631d8c..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 @@ -249,7 +265,7 @@ internal class WindowCallbackInterceptorTest { testedInterceptor.stopIntercepting() // Then - assertThat(installedCallback.shouldInstallCallbacks()).isFalse() + assertThat(installedCallback.shouldInstallCallbacks(fakeWindowsList.first())).isFalse() } @Test @@ -265,7 +281,43 @@ internal class WindowCallbackInterceptorTest { val captor = argumentCaptor() verify(fakeWindowsList.first(), times(2)).callback = captor.capture() val installedCallback = captor.lastValue as RecorderWindowCallback - assertThat(installedCallback.shouldInstallCallbacks()).isTrue() + 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 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 index 8480a6a574..f59a21d101 100644 --- 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 @@ -8,16 +8,20 @@ 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( @@ -28,9 +32,19 @@ import org.mockito.quality.Strictness @ForgeConfiguration(ForgeConfigurator::class) internal class WindowReflectionUtilsTest { + @Mock + lateinit var mockInternalLogger: InternalLogger + @Test - fun `M return null W getWindowFromDecorView {view has no mWindow field}`() { - assertThat(WindowReflectionUtils.getWindowFromDecorView(mock())).isNull() + 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 @@ -43,6 +57,8 @@ internal class WindowReflectionUtilsTest { } // When + Then - assertThat(WindowReflectionUtils.getWindowFromDecorView(fakeDecorLike)).isSameAs(fakeWindow) + 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 0172fa949f..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 @@ -592,20 +593,121 @@ internal class RecorderWindowCallbackTest { } @Test - fun `M skip zero-size window W window focus changed {decorView has zero dimensions}`() { + 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(mock())) // mock View defaults to width=0, height=0 - testedWindowCallback = buildCallbackFor(windowFromDecorView = { mockDialogWindow }) + .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 @@ -752,7 +854,7 @@ internal class RecorderWindowCallbackTest { private fun buildCallbackFor( windowFromDecorView: (View) -> Window? = { null }, onWindowWrapped: (Window) -> Unit = {}, - shouldInstallCallbacks: () -> Boolean = { true } + shouldInstallCallbacks: (Window) -> Boolean = { true } ) = RecorderWindowCallback( appContext = mockContext, recordedDataQueueHandler = mockRecordedDataQueueHandler,