Skip to content
Open
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
2 changes: 2 additions & 0 deletions detekt_custom_safe_calls_android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
2 changes: 2 additions & 0 deletions detekt_custom_safe_calls_third_party.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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?)"
Expand All @@ -410,6 +411,7 @@ datadog:
- "java.util.zip.Deflater.setInput(kotlin.ByteArray?)"
# endregion
# region Kotlin Stdlib
- "kotlin.Float.isNaN()"
- "kotlin.lazy(kotlin.Function0)"
- "kotlin.lazy(kotlin.LazyThreadSafetyMode, kotlin.Function0)"
- "kotlin.repeat(kotlin.Int, kotlin.Function1)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ internal class SemanticsUtils(
private val reflectionUtils: ReflectionUtils = ReflectionUtils(),
private val sampler: RateBasedSampler<Unit> = 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 {
Expand Down Expand Up @@ -384,7 +387,8 @@ internal class SemanticsUtils(
text = multiParagraphCapturedText ?: resolveAnnotatedString(layoutInput.text),
color = modifierColor?.value ?: layoutInput.style.color.value,
textAlign = layoutInput.style.textAlign,
fontSize = layoutInput.style.fontSize.value.toLong(),
fontSize = layoutInput.style.fontSize.value
.let { if (it.isNaN()) DEFAULT_FONT_SIZE_SP else it.toLong() },
fontFamily = layoutInput.style.fontFamily,
textOverflow = textOverflow
)
Expand Down Expand Up @@ -475,6 +479,7 @@ internal class SemanticsUtils(
private const val OVERFLOW_TYPE_KEY = "overflow.type"
private const val ERROR_TYPE_KEY = "error.type"

internal const val DEFAULT_FONT_SIZE_SP = 12L
internal const val TEXT_OVERFLOW_CLIP = 1
internal const val TEXT_OVERFLOW_ELLIPSE = 2
internal const val TEXT_OVERFLOW_VISIBLE = 3
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -224,6 +226,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder {

this.uiHandler = Handler(Looper.getMainLooper())
this.internalLogger = internalLogger
this.windowFromDecorView = { WindowReflectionUtils.getWindowFromDecorView(it, internalLogger) }
}

@VisibleForTesting
Expand All @@ -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
Expand All @@ -253,6 +259,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder {
this.sessionReplayLifecycleCallback = sessionReplayLifecycleCallback
this.resourceResolver = resourceResolver
this.uiHandler = uiHandler
this.windowFromDecorView = windowFromDecorView
this.internalLogger = internalLogger
}

Expand All @@ -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)
}
}
Expand All @@ -292,7 +299,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder {
override fun onWindowsAdded(windows: List<Window>) {
if (shouldRecord) {
val decorViews = windowInspector.getGlobalWindowViews(internalLogger)
windowCallbackInterceptor.intercept(windows, appContext)
windowCallbackInterceptor.intercept(windows + resolveUntrackedWindows(decorViews, windows), appContext)

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 Keep paused windows excluded during lifecycle intercepts

When activity A pauses but its decor remains in the global roots and activity B then resumes, windows contains only B, so resolveUntrackedWindows(decorViews, windows) classifies A as untracked and passes it back to intercept(). That method removes every supplied window from excludedWindows and wraps it, undoing the paused-window protection. The fresh evidence beyond the previously addressed dynamic-scan issue is this lifecycle path bypassing the exclusion predicate; resolve against all currently active lifecycle windows or preserve exclusions when adding discovered dialogs.

Useful? React with 👍 / 👎.

viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy)
}
}
Expand All @@ -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<View>, knownWindows: List<Window>): List<Window> {
return decorViews
.filterNot { it.width == 0 || it.height == 0 }
.mapNotNull { windowFromDecorView(it) }
.filterNot { it in knownWindows }
.distinct()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,28 +31,52 @@ internal class WindowCallbackInterceptor(
) {
private val wrappedWindows: WeakHashMap<Window, Any?> = 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<Window, Any?> = 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<Window>, appContext: Context) {
isStopped = false
windows.forEach { window ->
excludedWindows.remove(window)
wrapWindowCallback(window, appContext)
wrappedWindows[window] = null
}
}

fun stopIntercepting(windows: List<Window>) {
windows.forEach {
excludedWindows[it] = null
unwrapWindowCallback(it)
wrappedWindows.remove(it)
}
}

fun stopIntercepting() {
isStopped = true
wrappedWindows.entries.forEach {
unwrapWindowCallback(it.key)
}
wrappedWindows.clear()
excludedWindows.clear()
}

// a RecorderWindowCallback can discover and wrap windows on its own (e.g. dialogs, which
// aren't reported through the Activity lifecycle) — this lets it register those windows here
// so they get torn down by stopIntercepting() like any other tracked window.
internal fun trackWrappedWindow(window: Window) {
wrappedWindows[window] = null
}

private fun wrapWindowCallback(window: Window, appContext: Context) {
if (window.callback is RecorderWindowCallback) return
val toWrap = window.callback ?: NoOpWindowCallback()
window.callback = RecorderWindowCallback(
appContext,
Expand All @@ -64,7 +88,9 @@ internal class WindowCallbackInterceptor(
internalLogger,
textAndInputPrivacy,
imagePrivacy,
touchPrivacyManager
touchPrivacyManager,
onWindowWrapped = { trackWrappedWindow(it) },
shouldInstallCallbacks = { candidate -> !isStopped && !excludedWindows.containsKey(candidate) }
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.internal.recorder

import android.annotation.SuppressLint
import android.view.View
import android.view.Window
import com.datadog.android.api.InternalLogger

@SuppressLint("PrivateApi") // intentional: accessing mWindow via reflection to get the Window from a decor view
internal object WindowReflectionUtils {

internal const val FAILED_TO_RETRIEVE_WINDOW_ERROR_MESSAGE =
"SR WindowReflectionUtils failed to retrieve the Window from the decor view via reflection"

private const val WINDOW_FIELD_NAME = "mWindow"

fun getWindowFromDecorView(view: View, internalLogger: InternalLogger): Window? {
return try {
var currentClass: Class<*>? = view.javaClass
var lastFieldMiss: NoSuchFieldException? = null
while (currentClass != null) {
try {
@Suppress("UnsafeThirdPartyFunctionCall") // exceptions caught by outer try-catch
return currentClass.getDeclaredField(WINDOW_FIELD_NAME)
.also { it.isAccessible = true }
.get(view) as? Window
Comment thread
jonathanmos marked this conversation as resolved.
} catch (e: NoSuchFieldException) {
lastFieldMiss = e
currentClass = currentClass.superclass
}
}
// every real decorView declares mWindow somewhere in its hierarchy, so exhausting it
// without finding the field means hidden-API enforcement is blocking access on this
// OS version/OEM (it surfaces as NoSuchFieldException, indistinguishable from "absent").
lastFieldMiss?.let { logReflectionFailure(internalLogger, it) }
null
} catch (e: ReflectiveOperationException) {
logReflectionFailure(internalLogger, e)
null
} catch (e: SecurityException) {
logReflectionFailure(internalLogger, e)
null
}
}

private fun logReflectionFailure(internalLogger: InternalLogger, throwable: Throwable) {
internalLogger.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.TELEMETRY,
{ FAILED_TO_RETRIEVE_WINDOW_ERROR_MESSAGE },
throwable,
true
)
}
}
Loading
Loading