Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions detekt_custom_safe_calls_third_party.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,30 @@ internal class AndroidComposeViewMapper(
): List<MobileSegment.Wireframe> {
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) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid double-offsetting interop View wireframes

When a Compose dialog contains an AndroidView/interop node, RootSemanticsNodeMapper delegates it to mappingContext.interopViewCallback.map(), which traverses the Android View with the normal view mappers and DefaultViewBoundsResolver.getLocationOnScreen(), so those wireframes are already screen-absolute. Offsetting the entire returned list here shifts those interop wireframes by the dialog position a second time, causing embedded Android Views inside offset Compose roots/dialogs to appear in the wrong location.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll move this change into it's own pr

}

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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<MobileSegment.MobileRecord> = LinkedList()
private var lastOnMoveUpdateTimeInNs: Long = 0L
Expand Down Expand Up @@ -102,6 +106,7 @@ internal class RecorderWindowCallback(
textAndInputPrivacy = privacy,
imagePrivacy = imagePrivacy
)
installCallbackOnNewWindows(rootViews)
}
super.onWindowFocusChanged(hasFocus)
}
Expand All @@ -110,6 +115,51 @@ internal class RecorderWindowCallback(

// region Internal

private fun installCallbackOnNewWindows(rootViews: List<View>) {
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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track dynamically wrapped dialog callbacks for teardown

This new path installs a RecorderWindowCallback directly on dialog windows, but those windows are never added to WindowCallbackInterceptor's wrappedWindows set. If Session Replay is stopped while such a dialog is still open, SessionReplayRecorder.stopRecorders() calls windowCallbackInterceptor.stopIntercepting(), which can only restore callbacks it knows about, so this dialog keeps Datadog's callback installed and can continue intercepting focus/touch events after recording was stopped.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use raw coordinates for dialog touch privacy

When this installs the same callback on an offset dialog, dispatchTouchEvent() still checks TouchPrivacyManager with event.x/event.y, which are window-local for the dialog, while Android View override areas are added from getLocationOnScreen() during traversal. A dialog containing a view-level touch privacy override will therefore compare local touch points against screen-absolute rectangles, so touches inside the overridden dialog view can be incorrectly hidden or recorded.

Useful? React with 👍 / 👎.

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)) {
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<Window>()
val fakeDecorLike = object : View(mock()) {
@Suppress("unused")
private val mWindow: Window = fakeWindow
}

// When + Then
assertThat(WindowReflectionUtils.getWindowFromDecorView(fakeDecorLike)).isSameAs(fakeWindow)
}
}
Loading
Loading