Skip to content

Commit a2b5af3

Browse files
committed
Add more tests
1 parent cb97d71 commit a2b5af3

File tree

2 files changed

+117
-14
lines changed

2 files changed

+117
-14
lines changed

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

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,36 @@ internal object ComposeViewHierarchyNode {
4747
return@lazy null
4848
}
4949

50-
private fun LayoutNode.retrieveSemanticsConfiguration(logger: ILogger): SemanticsConfiguration? {
50+
private var semanticsRetrievalErrorLogged: Boolean = false
51+
52+
internal fun retrieveSemanticsConfiguration(node: LayoutNode, logger: ILogger): SemanticsConfiguration? {
5153
// Jetpack Compose 1.8 or newer provides SemanticsConfiguration via SemanticsInfo
5254
// See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
5355
// and https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
5456
try {
5557
getSemanticsConfigurationMethod?.let {
56-
return it.invoke(this) as SemanticsConfiguration?
58+
return it.invoke(node) as SemanticsConfiguration?
5759
}
58-
} catch (_: Throwable) {
59-
logger.log(
60-
SentryLevel.WARNING,
61-
"Failed to invoke LayoutNode.getSemanticsConfiguration"
62-
)
63-
}
6460

65-
// for backwards compatibility
66-
return collapsedSemantics
61+
// for backwards compatibility
62+
return node.collapsedSemantics
63+
} catch (t: Throwable) {
64+
if (!semanticsRetrievalErrorLogged) {
65+
semanticsRetrievalErrorLogged = true
66+
logger.log(
67+
SentryLevel.ERROR,
68+
t,
69+
"""
70+
Error retrieving semantics information from Compose tree. Most likely you're using
71+
an unsupported version of androidx.compose.ui:ui. The supported
72+
version range is 1.5.0 - 1.8.0.
73+
If you're using a newer version, please open a github issue with the version
74+
you're using, so we can add support for it.
75+
""".trimIndent()
76+
)
77+
}
78+
}
79+
return null
6780
}
6881

6982
/**
@@ -114,23 +127,41 @@ internal object ComposeViewHierarchyNode {
114127
_rootCoordinates = WeakReference(node.coordinates.findRootCoordinates())
115128
}
116129

117-
// TODO: if semantics are null and masking is enabled, we simply should mask the whole node
118-
val semantics = node.retrieveSemanticsConfiguration(options.logger)
130+
val semantics = retrieveSemanticsConfiguration(node, options.logger)
119131
val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates?.get())
120132
val isVisible = !node.outerCoordinator.isTransparent() &&
121133
(semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) &&
122134
visibleRect.height() > 0 && visibleRect.width() > 0
123135
val isEditable = semantics?.contains(SemanticsActions.SetText) == true ||
124136
semantics?.contains(SemanticsProperties.EditableText) == true
137+
138+
// If we're unable to retrieve the semantics configuration
139+
// we should play safe and mask the whole node.
140+
if (semantics == null) {
141+
return GenericViewHierarchyNode(
142+
x = visibleRect.left.toFloat(),
143+
y = visibleRect.top.toFloat(),
144+
width = node.width,
145+
height = node.height,
146+
elevation = (parent?.elevation ?: 0f),
147+
distance = distance,
148+
parent = parent,
149+
shouldMask = true,
150+
isImportantForContentCapture = false, /* will be set by children */
151+
isVisible = isVisible,
152+
visibleRect = visibleRect
153+
)
154+
}
155+
125156
return when {
126-
semantics?.contains(SemanticsProperties.Text) == true || isEditable -> {
157+
semantics.contains(SemanticsProperties.Text) || isEditable -> {
127158
val shouldMask = isVisible && semantics.shouldMask(isImage = false, options)
128159

129160
parent?.setImportantForCaptureToAncestors(true)
130161
// TODO: if we get reports that it's slow, we can drop this, and just mask
131162
// TODO: the whole view instead of per-line
132163
val textLayoutResults = mutableListOf<TextLayoutResult>()
133-
semantics?.getOrNull(SemanticsActions.GetTextLayoutResult)
164+
semantics.getOrNull(SemanticsActions.GetTextLayoutResult)
134165
?.action
135166
?.invoke(textLayoutResults)
136167

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

Lines changed: 72 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,7 +19,9 @@ 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
24+
import androidx.compose.ui.semantics.SemanticsConfiguration
1925
import androidx.compose.ui.semantics.clearAndSetSemantics
2026
import androidx.compose.ui.semantics.editableText
2127
import androidx.compose.ui.semantics.invisibleToUser
@@ -27,6 +33,8 @@ import androidx.compose.ui.unit.dp
2733
import androidx.compose.ui.unit.sp
2834
import androidx.test.ext.junit.runners.AndroidJUnit4
2935
import coil.compose.AsyncImage
36+
import io.sentry.ILogger
37+
import io.sentry.SentryLevel
3038
import io.sentry.SentryOptions
3139
import io.sentry.android.replay.maskAllImages
3240
import io.sentry.android.replay.maskAllText
@@ -39,15 +47,24 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarc
3947
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
4048
import org.junit.Before
4149
import org.junit.runner.RunWith
50+
import org.mockito.Mockito
51+
import org.mockito.kotlin.any
52+
import org.mockito.kotlin.eq
53+
import org.mockito.kotlin.mock
54+
import org.mockito.kotlin.verify
55+
import org.mockito.kotlin.verifyNoMoreInteractions
56+
import org.mockito.kotlin.whenever
4257
import org.robolectric.Robolectric.buildActivity
4358
import org.robolectric.Shadows.shadowOf
4459
import org.robolectric.annotation.Config
4560
import java.io.File
4661
import kotlin.test.Test
4762
import kotlin.test.assertEquals
4863
import kotlin.test.assertFalse
64+
import kotlin.test.assertNotNull
4965
import kotlin.test.assertNull
5066
import kotlin.test.assertTrue
67+
import kotlin.use
5168

5269
@RunWith(AndroidJUnit4::class)
5370
@Config(sdk = [30])
@@ -139,6 +156,45 @@ class ComposeMaskingOptionsTest {
139156
assertTrue(imageNodes.all { it.shouldMask })
140157
}
141158

159+
@Test
160+
fun `when retrieving the semantics fails, a node should be masked`() {
161+
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
162+
shadowOf(Looper.getMainLooper()).idle()
163+
val options = SentryOptions()
164+
165+
Mockito.mockStatic(ComposeViewHierarchyNode::class.java).use { utils ->
166+
utils.`when`<SemanticsConfiguration> { ComposeViewHierarchyNode.retrieveSemanticsConfiguration(any<LayoutNode>(), any()) }.thenReturn(null)
167+
168+
val root = activity.get().window.decorView
169+
val composeView = root.lookupComposeView()
170+
assertNotNull(composeView)
171+
172+
val rootNode = GenericViewHierarchyNode(0f, 0f, 0, 0, 1.0f, -1, shouldMask = true)
173+
ComposeViewHierarchyNode.fromView(composeView, rootNode, options)
174+
175+
assertEquals(1, rootNode.children?.size)
176+
177+
rootNode.traverse { node ->
178+
assertTrue(node.shouldMask)
179+
true
180+
}
181+
}
182+
}
183+
184+
@Test
185+
fun `when retrieving the semantics fails, an error is logged once`() {
186+
val logger = mock<ILogger>()
187+
188+
val node = mock<LayoutNode>()
189+
whenever(node.collapsedSemantics).thenThrow(RuntimeException("Compose Runtime Error"))
190+
191+
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node, logger)
192+
verify(logger).log(eq(SentryLevel.ERROR), any<RuntimeException>(), any<String>())
193+
194+
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node, logger)
195+
verifyNoMoreInteractions(logger)
196+
}
197+
142198
@Test
143199
fun `when maskAllImages is set to false all Image nodes are unmasked`() {
144200
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
@@ -246,6 +302,22 @@ class ComposeMaskingOptionsTest {
246302
}
247303
return nodes
248304
}
305+
306+
private fun View.lookupComposeView(): View? {
307+
if (this.javaClass.name.contains("AndroidComposeView")) {
308+
return this
309+
}
310+
if (this is ViewGroup) {
311+
for (i in 0 until childCount) {
312+
val child = getChildAt(i)
313+
val composeView = child.lookupComposeView()
314+
if (composeView != null) {
315+
return composeView
316+
}
317+
}
318+
}
319+
return null
320+
}
249321
}
250322

251323
private class ComposeMaskingOptionsActivity : ComponentActivity() {

0 commit comments

Comments
 (0)