Skip to content

Commit 3fa1209

Browse files
committed
RUM-16379: Fix recording dialog opening dialog in sr
1 parent b027fda commit 3fa1209

5 files changed

Lines changed: 249 additions & 3 deletions

File tree

detekt_custom_safe_calls_third_party.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,8 @@ datadog:
396396
- "java.util.zip.Deflater.setInput(kotlin.ByteArray?)"
397397
# endregion
398398
# region Kotlin Stdlib
399+
- "kotlin.Float.isNaN()"
400+
- "kotlin.Float.takeIf(kotlin.Function1)"
399401
- "kotlin.lazy(kotlin.Function0)"
400402
- "kotlin.lazy(kotlin.LazyThreadSafetyMode, kotlin.Function0)"
401403
- "kotlin.repeat(kotlin.Int, kotlin.Function1)"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.sessionreplay.internal.recorder
8+
9+
import android.annotation.SuppressLint
10+
import android.view.View
11+
import android.view.Window
12+
13+
@SuppressLint("PrivateApi")
14+
internal object WindowReflectionUtils {
15+
16+
private const val WINDOW_FIELD_NAME = "mWindow"
17+
18+
@Suppress("TooGenericExceptionCaught")
19+
fun getWindowFromDecorView(view: View): Window? {
20+
return try {
21+
var currentClass: Class<*>? = view.javaClass
22+
while (currentClass != null) {
23+
try {
24+
@Suppress("UnsafeThirdPartyFunctionCall") // exceptions caught by outer try-catch
25+
return currentClass.getDeclaredField(WINDOW_FIELD_NAME)
26+
.also { it.isAccessible = true }
27+
.get(view) as? Window
28+
} catch (_: NoSuchFieldException) {
29+
currentClass = currentClass.superclass
30+
}
31+
}
32+
null
33+
} catch (_: Exception) {
34+
null
35+
}
36+
}
37+
}

features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallback.kt

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package com.datadog.android.sessionreplay.internal.recorder.callback
99
import android.content.Context
1010
import android.graphics.Point
1111
import android.view.MotionEvent
12+
import android.view.View
1213
import android.view.Window
1314
import androidx.annotation.MainThread
1415
import com.datadog.android.api.InternalLogger
@@ -21,6 +22,7 @@ import com.datadog.android.sessionreplay.internal.TouchPrivacyManager
2122
import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler
2223
import com.datadog.android.sessionreplay.internal.recorder.ViewOnDrawInterceptor
2324
import com.datadog.android.sessionreplay.internal.recorder.WindowInspector
25+
import com.datadog.android.sessionreplay.internal.recorder.WindowReflectionUtils
2426
import com.datadog.android.sessionreplay.internal.utils.RumContextProvider
2527
import com.datadog.android.sessionreplay.model.MobileSegment
2628
import java.util.LinkedList
@@ -45,8 +47,10 @@ internal class RecorderWindowCallback(
4547
private val motionEventUtils: MotionEventUtils = MotionEventUtils,
4648
private val motionUpdateThresholdInNs: Long = MOTION_UPDATE_DELAY_THRESHOLD_NS,
4749
private val flushPositionBufferThresholdInNs: Long = FLUSH_BUFFER_THRESHOLD_NS,
48-
private val windowInspector: WindowInspector = WindowInspector
50+
private val windowInspector: WindowInspector = WindowInspector,
51+
private val windowFromDecorView: (View) -> Window? = { WindowReflectionUtils.getWindowFromDecorView(it) }
4952
) : FixedWindowCallback(wrappedCallback) {
53+
private val appContext: Context = appContext
5054
private val pixelsDensity = appContext.resources.displayMetrics.density
5155
internal val pointerInteractions: MutableList<MobileSegment.MobileRecord> = LinkedList()
5256
private var lastOnMoveUpdateTimeInNs: Long = 0L
@@ -94,14 +98,15 @@ internal class RecorderWindowCallback(
9498
override fun onWindowFocusChanged(hasFocus: Boolean) {
9599
val rootViews = windowInspector.getGlobalWindowViews(internalLogger)
96100
if (rootViews.isNotEmpty()) {
97-
// a new window was added or removed so we stop recording the previous root views
98-
// and we start recording the new ones.
99101
viewOnDrawInterceptor.stopIntercepting()
100102
viewOnDrawInterceptor.intercept(
101103
decorViews = rootViews,
102104
textAndInputPrivacy = privacy,
103105
imagePrivacy = imagePrivacy
104106
)
107+
// Ensure any Compose dialog {} window that appeared has a RecorderWindowCallback so
108+
// that if a further dialog opens on top of it, the focus-change chain continues.
109+
installCallbackOnNewWindows(rootViews)
105110
}
106111
super.onWindowFocusChanged(hasFocus)
107112
}
@@ -110,6 +115,53 @@ internal class RecorderWindowCallback(
110115

111116
// region Internal
112117

118+
private fun installCallbackOnNewWindows(rootViews: List<View>) {
119+
rootViews.forEach { decorView ->
120+
// Skip zero-size windows — they are NavHost internal scaffolding, not visible content.
121+
// Installing RecorderWindowCallback on them causes spurious stopIntercepting() calls
122+
// that drop recording frames on every navigation event.
123+
if (decorView.width == 0 || decorView.height == 0) return@forEach
124+
val window = windowFromDecorView(decorView)
125+
if (window == null) {
126+
internalLogger.log(
127+
InternalLogger.Level.WARN,
128+
listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY),
129+
{
130+
"SR: failed to get Window from ${decorView.javaClass.name} via reflection — " +
131+
"Compose dialog {} destination may not be recorded"
132+
},
133+
onlyOnce = true
134+
)
135+
return@forEach
136+
}
137+
if (window.callback !is RecorderWindowCallback) {
138+
// Post so the dialog's own onWindowFocusChanged(true) fires first through its
139+
// existing callback. By the time this runs, recording is already up-to-date
140+
// (the host window set it up when it lost focus), so the new callback starts
141+
// in a steady state with no events to skip.
142+
decorView.post {
143+
if (window.callback !is RecorderWindowCallback) {
144+
val toWrap = window.callback ?: NoOpWindowCallback()
145+
window.callback = RecorderWindowCallback(
146+
appContext = appContext,
147+
recordedDataQueueHandler = recordedDataQueueHandler,
148+
wrappedCallback = toWrap,
149+
timeProvider = timeProvider,
150+
rumContextProvider = rumContextProvider,
151+
viewOnDrawInterceptor = viewOnDrawInterceptor,
152+
internalLogger = internalLogger,
153+
privacy = privacy,
154+
imagePrivacy = imagePrivacy,
155+
touchPrivacyManager = touchPrivacyManager,
156+
windowInspector = windowInspector,
157+
windowFromDecorView = windowFromDecorView
158+
)
159+
}
160+
}
161+
}
162+
}
163+
}
164+
113165
@MainThread
114166
private fun handleEvent(event: MotionEvent) {
115167
when (event.action.and(MotionEvent.ACTION_MASK)) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.sessionreplay.internal.recorder
8+
9+
import android.view.View
10+
import android.view.Window
11+
import com.datadog.android.sessionreplay.forge.ForgeConfigurator
12+
import fr.xgouchet.elmyr.junit5.ForgeConfiguration
13+
import fr.xgouchet.elmyr.junit5.ForgeExtension
14+
import org.assertj.core.api.Assertions.assertThat
15+
import org.junit.jupiter.api.Test
16+
import org.junit.jupiter.api.extension.ExtendWith
17+
import org.junit.jupiter.api.extension.Extensions
18+
import org.mockito.junit.jupiter.MockitoExtension
19+
import org.mockito.junit.jupiter.MockitoSettings
20+
import org.mockito.kotlin.mock
21+
import org.mockito.quality.Strictness
22+
23+
@Extensions(
24+
ExtendWith(MockitoExtension::class),
25+
ExtendWith(ForgeExtension::class)
26+
)
27+
@MockitoSettings(strictness = Strictness.LENIENT)
28+
@ForgeConfiguration(ForgeConfigurator::class)
29+
internal class WindowReflectionUtilsTest {
30+
31+
@Test
32+
fun `M return null W getWindowFromDecorView {view has no mWindow field}`() {
33+
assertThat(WindowReflectionUtils.getWindowFromDecorView(mock())).isNull()
34+
}
35+
36+
@Test
37+
fun `M return Window W getWindowFromDecorView {view class has mWindow field}`() {
38+
// Given — anonymous subclass that mimics DecorView's private mWindow field
39+
val fakeWindow = mock<Window>()
40+
val fakeDecorLike = object : View(mock()) {
41+
@Suppress("unused")
42+
private val mWindow: Window = fakeWindow
43+
}
44+
45+
// When + Then
46+
assertThat(WindowReflectionUtils.getWindowFromDecorView(fakeDecorLike)).isSameAs(fakeWindow)
47+
}
48+
}

features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import org.mockito.kotlin.any
4747
import org.mockito.kotlin.argumentCaptor
4848
import org.mockito.kotlin.inOrder
4949
import org.mockito.kotlin.mock
50+
import org.mockito.kotlin.never
5051
import org.mockito.kotlin.times
5152
import org.mockito.kotlin.verify
5253
import org.mockito.kotlin.verifyNoInteractions
@@ -459,6 +460,91 @@ internal class RecorderWindowCallbackTest {
459460
}
460461
}
461462

463+
@Test
464+
fun `M install RecorderWindowCallback on new dialog window W window focus changed`(forge: Forge) {
465+
// Given
466+
val mockDialogWindow = mock<Window>()
467+
val mockDialogDecorView = mock<View>().also {
468+
whenever(it.width).thenReturn(800)
469+
whenever(it.height).thenReturn(600)
470+
}
471+
whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger))
472+
.thenReturn(listOf(mockDialogDecorView))
473+
testedWindowCallback = buildCallbackFor(
474+
windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null }
475+
)
476+
477+
testedWindowCallback.onWindowFocusChanged(forge.aBool())
478+
479+
// Callback installation is posted so the dialog's first focus event passes through
480+
// the original callback. Capture and run the Runnable to simulate the post.
481+
argumentCaptor<Runnable> {
482+
verify(mockDialogDecorView).post(capture())
483+
firstValue.run()
484+
}
485+
verify(mockDialogWindow).callback = any<RecorderWindowCallback>()
486+
}
487+
488+
@Test
489+
fun `M not replace existing RecorderWindowCallback W window focus changed`(forge: Forge) {
490+
val mockDialogWindow = mock<Window>().also {
491+
whenever(it.callback).thenReturn(mock<RecorderWindowCallback>())
492+
}
493+
val mockDialogDecorView = mock<View>().also {
494+
whenever(it.width).thenReturn(800)
495+
whenever(it.height).thenReturn(600)
496+
}
497+
whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger))
498+
.thenReturn(listOf(mockDialogDecorView))
499+
testedWindowCallback = buildCallbackFor(
500+
windowFromDecorView = { view -> if (view == mockDialogDecorView) mockDialogWindow else null }
501+
)
502+
503+
testedWindowCallback.onWindowFocusChanged(forge.aBool())
504+
505+
// Outer guard short-circuits before post() is called — nothing to install.
506+
verify(mockDialogDecorView, never()).post(any())
507+
verify(mockDialogWindow, never()).callback = any()
508+
}
509+
510+
@Test
511+
fun `M skip zero-size window W window focus changed {decorView has zero dimensions}`() {
512+
// Zero-size windows are NavHost scaffolding — installing RC on them drops recording frames.
513+
val mockDialogWindow = mock<Window>()
514+
whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger))
515+
.thenReturn(listOf(mock())) // mock View defaults to width=0, height=0
516+
testedWindowCallback = buildCallbackFor(windowFromDecorView = { mockDialogWindow })
517+
518+
testedWindowCallback.onWindowFocusChanged(false)
519+
520+
verify(mockDialogWindow, never()).callback = any()
521+
}
522+
523+
@Test
524+
fun `M log warning and skip W window focus changed {windowFromDecorView returns null}`() {
525+
// Given
526+
val mockDialogDecorView = mock<View>().also {
527+
whenever(it.width).thenReturn(800)
528+
whenever(it.height).thenReturn(600)
529+
}
530+
whenever(mockWindowInspector.getGlobalWindowViews(mockInternalLogger))
531+
.thenReturn(listOf(mockDialogDecorView))
532+
testedWindowCallback = buildCallbackFor(windowFromDecorView = { null })
533+
534+
// When
535+
testedWindowCallback.onWindowFocusChanged(false)
536+
537+
// Then
538+
val expectedMessage = "SR: failed to get Window from ${mockDialogDecorView.javaClass.name}" +
539+
" via reflection — Compose dialog {} destination may not be recorded"
540+
mockInternalLogger.verifyLog(
541+
InternalLogger.Level.WARN,
542+
listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY),
543+
expectedMessage,
544+
onlyOnce = true
545+
)
546+
}
547+
462548
@Test
463549
fun `M do nothing W window focus changed {decorViews could not be fetched}`(forge: Forge) {
464550
// Given
@@ -577,6 +663,27 @@ internal class RecorderWindowCallbackTest {
577663

578664
// region Internal
579665

666+
private fun buildCallbackFor(
667+
windowFromDecorView: (View) -> Window? = { null }
668+
) = RecorderWindowCallback(
669+
appContext = mockContext,
670+
recordedDataQueueHandler = mockRecordedDataQueueHandler,
671+
wrappedCallback = mockWrappedCallback,
672+
timeProvider = mockTimeProvider,
673+
rumContextProvider = mockRumContextProvider,
674+
viewOnDrawInterceptor = mockViewOnDrawInterceptor,
675+
internalLogger = mockInternalLogger,
676+
privacy = fakeTextAndInputPrivacy,
677+
imagePrivacy = ImagePrivacy.MASK_NONE,
678+
touchPrivacyManager = mockTouchPrivacyManager,
679+
copyEvent = { it },
680+
motionEventUtils = mockEventUtils,
681+
motionUpdateThresholdInNs = TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS,
682+
flushPositionBufferThresholdInNs = TEST_FLUSH_BUFFER_THRESHOLD_NS,
683+
windowInspector = mockWindowInspector,
684+
windowFromDecorView = windowFromDecorView
685+
)
686+
580687
private fun Forge.touchRecords(
581688
eventType: MobileSegment.PointerEventType
582689
): List<MobileRecord.MobileIncrementalSnapshotRecord> {

0 commit comments

Comments
 (0)