From ffe1117073e8be9655c520bba6965919211df885 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Jun 2026 10:18:03 +0200 Subject: [PATCH 1/3] fix(replay): Fix VerifyError in Compose masking under DexGuard/R8 obfuscation ComposeViewHierarchyNode.boundsInWindow returned an android.graphics.Rect while the surrounding code carried it as androidx.compose.ui.geometry.Rect, mixing the two Rect types in the same method. Under aggressive obfuscation (DexGuard 9.13.2 / R8 full mode) this could be rejected at class load with a VerifyError, crashing Replay when traversing the Compose tree. Make boundsInWindow return androidx.compose.ui.geometry.Rect throughout and add a Rect.toRect() extension to convert to android.graphics.Rect only at the boundary where the view-hierarchy node needs it. Fixes #5497 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/replay/util/Nodes.kt | 10 ++++-- .../viewhierarchy/ComposeViewHierarchyNode.kt | 33 ++++++++++--------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index 2882b2113b8..a29120a79b6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -2,8 +2,8 @@ package io.sentry.android.replay.util -import android.graphics.Rect import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.painter.Painter @@ -176,7 +176,7 @@ internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) if (boundsLeft == boundsRight || boundsTop == boundsBottom) { - return Rect() + return Rect(0.0f, 0.0f, 0.0f, 0.0f) } val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) @@ -200,5 +200,9 @@ internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) - return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + return Rect(left, top, right, bottom) +} + +internal fun Rect.toRect(): android.graphics.Rect { + return android.graphics.Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index a0312b69cd0..35800ecd88d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -27,6 +27,7 @@ import io.sentry.android.replay.util.findPainter import io.sentry.android.replay.util.findTextColor import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.util.toRect import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -150,8 +151,8 @@ internal object ComposeViewHierarchyNode { // If we're unable to retrieve the semantics configuration // we should play safe and mask the whole node. return GenericViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -161,17 +162,17 @@ internal object ComposeViewHierarchyNode { isImportantForContentCapture = false, // will be set by children isVisible = !SentryLayoutNodeHelper.isTransparent(node) && - visibleRect.height() > 0 && - visibleRect.width() > 0, - visibleRect = visibleRect, + visibleRect.height > 0 && + visibleRect.width > 0, + visibleRect = visibleRect.toRect(), ) } val isVisible = !SentryLayoutNodeHelper.isTransparent(node) && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && - visibleRect.height() > 0 && - visibleRect.width() > 0 + visibleRect.height > 0 && + visibleRect.width > 0 val isEditable = semantics?.contains(SemanticsActions.SetText) == true || semantics?.contains(SemanticsProperties.EditableText) == true @@ -206,8 +207,8 @@ internal object ComposeViewHierarchyNode { null }, dominantColor = textColor?.toArgb()?.toOpaque(), - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -216,7 +217,7 @@ internal object ComposeViewHierarchyNode { shouldMask = shouldMask, isImportantForContentCapture = true, isVisible = isVisible, - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } else -> { @@ -226,8 +227,8 @@ internal object ComposeViewHierarchyNode { parent?.setImportantForCaptureToAncestors(true) ImageViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -236,7 +237,7 @@ internal object ComposeViewHierarchyNode { isVisible = isVisible, isImportantForContentCapture = true, shouldMask = shouldMask && painter.isMaskable(), - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } else { val shouldMask = isVisible && semantics.shouldMask(isImage = false, options) @@ -245,8 +246,8 @@ internal object ComposeViewHierarchyNode { // TODO: traverse the ViewHierarchyNode here again. For now we can recommend // TODO: using custom modifiers to obscure the entire node if it's sensitive GenericViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -255,7 +256,7 @@ internal object ComposeViewHierarchyNode { shouldMask = shouldMask, isImportantForContentCapture = false, // will be set by children isVisible = isVisible, - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } } From e56c8634d633390e95ad8619b611dddd2ed599fb Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Jun 2026 10:26:15 +0200 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..a9ee3171fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Session Replay: Fix `VerifyError` in Compose masking under DexGuard/R8 obfuscation ([#5507](https://github.com/getsentry/sentry-java/pull/5507)) + ## 8.43.1 ### Fixes From ceb765439b170fdb945c80a2e033a6867bcda082 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Jun 2026 13:03:38 +0200 Subject: [PATCH 3/3] fix(replay): Round Compose mask bounds outward to avoid zero-area masks isVisible/shouldMask are derived from the sub-pixel float bounds, but the android.graphics.Rect stored on the node (and drawn by MaskRenderer) used truncating toInt(). A sub-pixel node could be marked visible+maskable yet store a zero-width/height rect, so the mask wasn't drawn and sensitive content leaked. Round outward (floor min, ceil max) so a non-empty float rect always yields a non-empty integer rect, biasing toward over-masking. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/io/sentry/android/replay/util/Nodes.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index a29120a79b6..028f681d96b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.text.TextLayoutResult +import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.roundToInt internal class ComposeTextLayout(internal val layout: TextLayoutResult) : TextLayout { @@ -204,5 +206,14 @@ internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates } internal fun Rect.toRect(): android.graphics.Rect { - return android.graphics.Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + // Round outward (floor min edges, ceil max edges) so that a sub-pixel but non-empty Rect doesn't + // collapse to a zero-width/height android.graphics.Rect. Otherwise a node could be marked visible + // and maskable based on the float bounds, while the integer rect the MaskRenderer draws has zero + // area, leaving sensitive content unmasked. Rounding outward also biases toward over-masking. + return android.graphics.Rect( + floor(left).toInt(), + floor(top).toInt(), + ceil(right).toInt(), + ceil(bottom).toInt(), + ) }