From 108296fbba7fd785e1b34bff6f7ab0a11ecba8af 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/4] 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 f69ebf5fb5..85072c4943 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 @@ -376,7 +376,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 ) @@ -467,6 +468,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 64a7cd9ef2..cba5fee366 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 @@ -615,6 +615,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 1fbb3c3a36798b26bde3be170b4b74ea1e3f3347 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/4] 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 309e2b66b64d81ff8972464698b6da6c5ade9faf Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:24:25 +0300 Subject: [PATCH 3/4] RUM-16379: Fix dialog opening from another dialog in sr --- .../semantics/AndroidComposeViewMapper.kt | 20 ++++++++- .../semantics/AndroidComposeViewMapperTest.kt | 43 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AndroidComposeViewMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AndroidComposeViewMapper.kt index 1da7b21aa7..acf917f1da 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AndroidComposeViewMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AndroidComposeViewMapper.kt @@ -41,12 +41,30 @@ internal class AndroidComposeViewMapper( ): List { val density = mappingContext.systemInformation.screenDensity.let { if (it == 0.0f) 1.0f else it } + // positionInRoot is relative to the AndroidComposeView, not the screen — offset to match + // the screen-absolute coordinates that all other SR wireframe mappers produce. + val screenPos = IntArray(2) + @Suppress("UnsafeThirdPartyFunctionCall") // array is always size 2 as required + view.getLocationOnScreen(screenPos) + val windowOffsetX = (screenPos[0] / density).toLong() + val windowOffsetY = (screenPos[1] / density).toLong() return rootSemanticsNodeMapper.createComposeWireframes( view.semanticsOwner.unmergedRootSemanticsNode, density, mappingContext, asyncJobStatusCallback, internalLogger - ) + ).map { it.withWindowOffset(windowOffsetX, windowOffsetY) } + } + + private fun MobileSegment.Wireframe.withWindowOffset(x: Long, y: Long): MobileSegment.Wireframe { + if (x == 0L && y == 0L) return this + return when (this) { + is MobileSegment.Wireframe.ShapeWireframe -> copy(x = this.x + x, y = this.y + y) + is MobileSegment.Wireframe.TextWireframe -> copy(x = this.x + x, y = this.y + y) + is MobileSegment.Wireframe.ImageWireframe -> copy(x = this.x + x, y = this.y + y) + is MobileSegment.Wireframe.PlaceholderWireframe -> copy(x = this.x + x, y = this.y + y) + is MobileSegment.Wireframe.WebviewWireframe -> copy(x = this.x + x, y = this.y + y) + } } } diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AndroidComposeViewMapperTest.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AndroidComposeViewMapperTest.kt index da73b427ed..6783a4da8c 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AndroidComposeViewMapperTest.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/AndroidComposeViewMapperTest.kt @@ -17,15 +17,18 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator +import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.ColorStringFormatter import com.datadog.android.sessionreplay.utils.DrawableToColorMapper import com.datadog.android.sessionreplay.utils.ViewBoundsResolver import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver +import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -34,6 +37,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -120,6 +124,45 @@ class AndroidComposeViewMapperTest { ) } + @Test + fun `M apply window offset to wireframes W map {view has non-zero screen position}`(forge: Forge) { + // Given + val fakeScreenX = forge.anInt(min = 1, max = 1000) + val fakeScreenY = forge.anInt(min = 1, max = 1000) + val fakeWireframe = MobileSegment.Wireframe.ShapeWireframe( + id = forge.aLong(), + x = forge.aLong(min = 0, max = 500), + y = forge.aLong(min = 0, max = 500), + width = forge.aLong(min = 1, max = 500), + height = forge.aLong(min = 1, max = 500) + ) + val mockSemanticsNode = mockSemanticsNode(null) + whenever(mockAndroidComposeView.semanticsOwner).thenReturn(mockSemanticsOwner) + whenever(mockSemanticsOwner.unmergedRootSemanticsNode).thenReturn(mockSemanticsNode) + whenever(mockRootSemanticsNodeMapper.createComposeWireframes(any(), any(), any(), any(), any())) + .thenReturn(listOf(fakeWireframe)) + doAnswer { invocation -> + val array = invocation.arguments[0] as IntArray + array[0] = fakeScreenX + array[1] = fakeScreenY + null + }.whenever(mockAndroidComposeView).getLocationOnScreen(any()) + + // When + val result = testedAndroidComposeViewMapper.map( + mockAndroidComposeView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + + // Then + val density = fakeMappingContext.systemInformation.screenDensity.let { if (it == 0.0f) 1.0f else it } + val wireframe = result.first() as MobileSegment.Wireframe.ShapeWireframe + assertThat(wireframe.x).isEqualTo(fakeWireframe.x + (fakeScreenX / density).toLong()) + assertThat(wireframe.y).isEqualTo(fakeWireframe.y + (fakeScreenY / density).toLong()) + } + private fun mockSemanticsNode(role: Role?): SemanticsNode { return mock { whenever(mockSemanticsConfiguration.getOrNull(SemanticsProperties.Role)) doReturn role From 17a292a2c8db16df6eb098afbe5ce1c452cd599a 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 4/4] 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 d2cf5d4fc6..045dac76c3 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -396,6 +396,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 {