Skip to content

Commit 8bfa9cc

Browse files
authored
Fix replay masking for Jetpack Compose 1.8+ (#4485)
* Fix broken view hierarchy retrieval for Jetpack Compose 1.8+ * Update Changelog * Fix tests * Update Changelog * Add more tests * Allow null semantics, but mask in case an exception gets thrown * Address PR feedback
1 parent e56c1d1 commit 8bfa9cc

File tree

3 files changed

+144
-11
lines changed

3 files changed

+144
-11
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Fixes
6+
7+
- Fix Session Replay masking for newer versions of Jetpack Compose (1.8+) ([#4485](https://github.com/getsentry/sentry-java/pull/4485))
8+
59
### Features
610

711
- Add New User Feedback form ([#4384](https://github.com/getsentry/sentry-java/pull/4384))

sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import androidx.compose.ui.layout.findRootCoordinates
1111
import androidx.compose.ui.node.LayoutNode
1212
import androidx.compose.ui.node.Owner
1313
import androidx.compose.ui.semantics.SemanticsActions
14+
import androidx.compose.ui.semantics.SemanticsConfiguration
1415
import androidx.compose.ui.semantics.SemanticsProperties
1516
import androidx.compose.ui.semantics.getOrNull
1617
import androidx.compose.ui.text.TextLayoutResult
@@ -29,26 +30,51 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera
2930
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
3031
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
3132
import java.lang.ref.WeakReference
33+
import java.lang.reflect.Method
3234

3335
@TargetApi(26)
3436
internal object ComposeViewHierarchyNode {
3537

38+
private val getSemanticsConfigurationMethod: Method? by lazy {
39+
try {
40+
return@lazy LayoutNode::class.java.getDeclaredMethod("getSemanticsConfiguration").apply {
41+
isAccessible = true
42+
}
43+
} catch (_: Throwable) {
44+
// ignore, as this method may not be available
45+
}
46+
return@lazy null
47+
}
48+
49+
private var semanticsRetrievalErrorLogged: Boolean = false
50+
51+
@JvmStatic
52+
internal fun retrieveSemanticsConfiguration(node: LayoutNode): SemanticsConfiguration? {
53+
// Jetpack Compose 1.8 or newer provides SemanticsConfiguration via SemanticsInfo
54+
// See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
55+
// and https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
56+
getSemanticsConfigurationMethod?.let {
57+
return it.invoke(node) as SemanticsConfiguration?
58+
}
59+
60+
// for backwards compatibility
61+
return node.collapsedSemantics
62+
}
63+
3664
/**
3765
* Since Compose doesn't have a concept of a View class (they are all composable functions),
3866
* we need to map the semantics node to a corresponding old view system class.
3967
*/
40-
private fun LayoutNode.getProxyClassName(isImage: Boolean): String {
68+
private fun getProxyClassName(isImage: Boolean, config: SemanticsConfiguration?): String {
4169
return when {
4270
isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME
43-
collapsedSemantics?.contains(SemanticsProperties.Text) == true ||
44-
collapsedSemantics?.contains(SemanticsActions.SetText) == true ||
45-
collapsedSemantics?.contains(SemanticsProperties.EditableText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME
71+
config != null && (config.contains(SemanticsProperties.Text) || config.contains(SemanticsActions.SetText) || config.contains(SemanticsProperties.EditableText)) -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME
4672
else -> "android.view.View"
4773
}
4874
}
4975

50-
private fun LayoutNode.shouldMask(isImage: Boolean, options: SentryOptions): Boolean {
51-
val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy)
76+
private fun SemanticsConfiguration?.shouldMask(isImage: Boolean, options: SentryOptions): Boolean {
77+
val sentryPrivacyModifier = this?.getOrNull(SentryReplayModifiers.SentryPrivacy)
5278
if (sentryPrivacyModifier == "unmask") {
5379
return false
5480
}
@@ -57,7 +83,7 @@ internal object ComposeViewHierarchyNode {
5783
return true
5884
}
5985

60-
val className = getProxyClassName(isImage)
86+
val className = getProxyClassName(isImage, this)
6187
if (options.sessionReplay.unmaskViewClasses.contains(className)) {
6288
return false
6389
}
@@ -83,16 +109,53 @@ internal object ComposeViewHierarchyNode {
83109
_rootCoordinates = WeakReference(node.coordinates.findRootCoordinates())
84110
}
85111

86-
val semantics = node.collapsedSemantics
87112
val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates?.get())
113+
val semantics: SemanticsConfiguration?
114+
115+
try {
116+
semantics = retrieveSemanticsConfiguration(node)
117+
} catch (t: Throwable) {
118+
if (!semanticsRetrievalErrorLogged) {
119+
semanticsRetrievalErrorLogged = true
120+
options.logger.log(
121+
SentryLevel.ERROR,
122+
t,
123+
"""
124+
Error retrieving semantics information from Compose tree. Most likely you're using
125+
an unsupported version of androidx.compose.ui:ui. The supported
126+
version range is 1.5.0 - 1.8.0.
127+
If you're using a newer version, please open a github issue with the version
128+
you're using, so we can add support for it.
129+
""".trimIndent()
130+
)
131+
}
132+
133+
// If we're unable to retrieve the semantics configuration
134+
// we should play safe and mask the whole node.
135+
return GenericViewHierarchyNode(
136+
x = visibleRect.left.toFloat(),
137+
y = visibleRect.top.toFloat(),
138+
width = node.width,
139+
height = node.height,
140+
elevation = (parent?.elevation ?: 0f),
141+
distance = distance,
142+
parent = parent,
143+
shouldMask = true,
144+
isImportantForContentCapture = false, /* will be set by children */
145+
isVisible = !node.outerCoordinator.isTransparent() && visibleRect.height() > 0 && visibleRect.width() > 0,
146+
visibleRect = visibleRect
147+
)
148+
}
149+
88150
val isVisible = !node.outerCoordinator.isTransparent() &&
89151
(semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) &&
90152
visibleRect.height() > 0 && visibleRect.width() > 0
91153
val isEditable = semantics?.contains(SemanticsActions.SetText) == true ||
92154
semantics?.contains(SemanticsProperties.EditableText) == true
155+
93156
return when {
94157
semantics?.contains(SemanticsProperties.Text) == true || isEditable -> {
95-
val shouldMask = isVisible && node.shouldMask(isImage = false, options)
158+
val shouldMask = isVisible && semantics.shouldMask(isImage = false, options)
96159

97160
parent?.setImportantForCaptureToAncestors(true)
98161
// TODO: if we get reports that it's slow, we can drop this, and just mask
@@ -133,7 +196,7 @@ internal object ComposeViewHierarchyNode {
133196
else -> {
134197
val painter = node.findPainter()
135198
if (painter != null) {
136-
val shouldMask = isVisible && node.shouldMask(isImage = true, options)
199+
val shouldMask = isVisible && semantics.shouldMask(isImage = true, options)
137200

138201
parent?.setImportantForCaptureToAncestors(true)
139202
ImageViewHierarchyNode(
@@ -150,7 +213,7 @@ internal object ComposeViewHierarchyNode {
150213
visibleRect = visibleRect
151214
)
152215
} else {
153-
val shouldMask = isVisible && node.shouldMask(isImage = false, options)
216+
val shouldMask = isVisible && semantics.shouldMask(isImage = false, options)
154217

155218
// TODO: this currently does not support embedded AndroidViews, we'd have to
156219
// TODO: traverse the ViewHierarchyNode here again. For now we can recommend

sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
2+
13
package io.sentry.android.replay.viewhierarchy
24

35
import android.app.Activity
46
import android.net.Uri
57
import android.os.Bundle
68
import android.os.Looper
9+
import android.view.View
10+
import android.view.ViewGroup
711
import androidx.activity.ComponentActivity
812
import androidx.activity.compose.setContent
913
import androidx.compose.foundation.layout.Arrangement
@@ -15,6 +19,7 @@ import androidx.compose.material3.Text
1519
import androidx.compose.material3.TextField
1620
import androidx.compose.ui.Alignment
1721
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.node.LayoutNode
1823
import androidx.compose.ui.platform.testTag
1924
import androidx.compose.ui.semantics.clearAndSetSemantics
2025
import androidx.compose.ui.semantics.editableText
@@ -37,15 +42,22 @@ import io.sentry.android.replay.util.traverse
3742
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode
3843
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
3944
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
45+
import org.junit.Assert.assertThrows
4046
import org.junit.Before
4147
import org.junit.runner.RunWith
48+
import org.mockito.MockedStatic
49+
import org.mockito.Mockito
50+
import org.mockito.kotlin.any
51+
import org.mockito.kotlin.mock
52+
import org.mockito.kotlin.whenever
4253
import org.robolectric.Robolectric.buildActivity
4354
import org.robolectric.Shadows.shadowOf
4455
import org.robolectric.annotation.Config
4556
import java.io.File
4657
import kotlin.test.Test
4758
import kotlin.test.assertEquals
4859
import kotlin.test.assertFalse
60+
import kotlin.test.assertNotNull
4961
import kotlin.test.assertNull
5062
import kotlin.test.assertTrue
5163

@@ -139,6 +151,44 @@ class ComposeMaskingOptionsTest {
139151
assertTrue(imageNodes.all { it.shouldMask })
140152
}
141153

154+
@Test
155+
fun `when retrieving the semantics fails, a node should be masked`() {
156+
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
157+
shadowOf(Looper.getMainLooper()).idle()
158+
val options = SentryOptions()
159+
160+
Mockito.mockStatic(ComposeViewHierarchyNode.javaClass)
161+
.use { mock: MockedStatic<ComposeViewHierarchyNode> ->
162+
mock.`when`<Any> {
163+
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(any<LayoutNode>())
164+
}.thenThrow(RuntimeException())
165+
166+
val root = activity.get().window.decorView
167+
val composeView = root.lookupComposeView()
168+
assertNotNull(composeView)
169+
170+
val rootNode = GenericViewHierarchyNode(0f, 0f, 0, 0, 1.0f, -1, shouldMask = true)
171+
ComposeViewHierarchyNode.fromView(composeView, rootNode, options)
172+
173+
assertEquals(1, rootNode.children?.size)
174+
175+
rootNode.traverse { node ->
176+
assertTrue(node.shouldMask)
177+
true
178+
}
179+
}
180+
}
181+
182+
@Test
183+
fun `when retrieving the semantics fails, an error is thrown`() {
184+
val node = mock<LayoutNode>()
185+
whenever(node.collapsedSemantics).thenThrow(RuntimeException("Compose Runtime Error"))
186+
187+
assertThrows(RuntimeException::class.java) {
188+
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node)
189+
}
190+
}
191+
142192
@Test
143193
fun `when maskAllImages is set to false all Image nodes are unmasked`() {
144194
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
@@ -246,6 +296,22 @@ class ComposeMaskingOptionsTest {
246296
}
247297
return nodes
248298
}
299+
300+
private fun View.lookupComposeView(): View? {
301+
if (this.javaClass.name.contains("AndroidComposeView")) {
302+
return this
303+
}
304+
if (this is ViewGroup) {
305+
for (i in 0 until childCount) {
306+
val child = getChildAt(i)
307+
val composeView = child.lookupComposeView()
308+
if (composeView != null) {
309+
return composeView
310+
}
311+
}
312+
}
313+
return null
314+
}
249315
}
250316

251317
private class ComposeMaskingOptionsActivity : ComponentActivity() {

0 commit comments

Comments
 (0)