Skip to content

Commit c738037

Browse files
markushiromtsn
authored andcommitted
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 9a31afc commit c738037

File tree

3 files changed

+143
-11
lines changed

3 files changed

+143
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- Session Replay: Fix masking of non-styled `Text` Composables ([#4361](https://github.com/getsentry/sentry-java/pull/4361))
1717
- Session Replay: Fix masking read-only `TextField` Composables ([#4362](https://github.com/getsentry/sentry-java/pull/4362))
1818
- Correctly capture Dialogs and non full-sized windows ([#4354](https://github.com/getsentry/sentry-java/pull/4354))
19+
- Fix Session Replay masking for newer versions of Jetpack Compose (1.8+) ([#4485](https://github.com/getsentry/sentry-java/pull/4485))
1920

2021
## 7.22.5
2122

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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +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
8+
import android.os.Looper
9+
import android.view.View
10+
import android.view.ViewGroup
611
import androidx.activity.ComponentActivity
712
import androidx.activity.compose.setContent
813
import androidx.compose.foundation.layout.Arrangement
@@ -14,6 +19,7 @@ import androidx.compose.material3.Text
1419
import androidx.compose.material3.TextField
1520
import androidx.compose.ui.Alignment
1621
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.node.LayoutNode
1723
import androidx.compose.ui.platform.testTag
1824
import androidx.compose.ui.semantics.clearAndSetSemantics
1925
import androidx.compose.ui.semantics.editableText
@@ -36,14 +42,22 @@ import io.sentry.android.replay.util.traverse
3642
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode
3743
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
3844
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
45+
import org.junit.Assert.assertThrows
3946
import org.junit.Before
4047
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
4153
import org.robolectric.Robolectric.buildActivity
54+
import org.robolectric.Shadows.shadowOf
4255
import org.robolectric.annotation.Config
4356
import java.io.File
4457
import kotlin.test.Test
4558
import kotlin.test.assertEquals
4659
import kotlin.test.assertFalse
60+
import kotlin.test.assertNotNull
4761
import kotlin.test.assertNull
4862
import kotlin.test.assertTrue
4963

@@ -133,6 +147,44 @@ class ComposeMaskingOptionsTest {
133147
assertTrue(imageNodes.all { it.shouldMask })
134148
}
135149

150+
@Test
151+
fun `when retrieving the semantics fails, a node should be masked`() {
152+
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
153+
shadowOf(Looper.getMainLooper()).idle()
154+
val options = SentryOptions()
155+
156+
Mockito.mockStatic(ComposeViewHierarchyNode.javaClass)
157+
.use { mock: MockedStatic<ComposeViewHierarchyNode> ->
158+
mock.`when`<Any> {
159+
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(any<LayoutNode>())
160+
}.thenThrow(RuntimeException())
161+
162+
val root = activity.get().window.decorView
163+
val composeView = root.lookupComposeView()
164+
assertNotNull(composeView)
165+
166+
val rootNode = GenericViewHierarchyNode(0f, 0f, 0, 0, 1.0f, -1, shouldMask = true)
167+
ComposeViewHierarchyNode.fromView(composeView, rootNode, options)
168+
169+
assertEquals(1, rootNode.children?.size)
170+
171+
rootNode.traverse { node ->
172+
assertTrue(node.shouldMask)
173+
true
174+
}
175+
}
176+
}
177+
178+
@Test
179+
fun `when retrieving the semantics fails, an error is thrown`() {
180+
val node = mock<LayoutNode>()
181+
whenever(node.collapsedSemantics).thenThrow(RuntimeException("Compose Runtime Error"))
182+
183+
assertThrows(RuntimeException::class.java) {
184+
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node)
185+
}
186+
}
187+
136188
@Test
137189
fun `when maskAllImages is set to false all Image nodes are unmasked`() {
138190
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
@@ -235,6 +287,22 @@ class ComposeMaskingOptionsTest {
235287
}
236288
return nodes
237289
}
290+
291+
private fun View.lookupComposeView(): View? {
292+
if (this.javaClass.name.contains("AndroidComposeView")) {
293+
return this
294+
}
295+
if (this is ViewGroup) {
296+
for (i in 0 until childCount) {
297+
val child = getChildAt(i)
298+
val composeView = child.lookupComposeView()
299+
if (composeView != null) {
300+
return composeView
301+
}
302+
}
303+
}
304+
return null
305+
}
238306
}
239307

240308
private class ComposeMaskingOptionsActivity : ComponentActivity() {

0 commit comments

Comments
 (0)