Skip to content

Commit 02931bf

Browse files
authored
fix(replay): Mask read-only TextField Composables (#4362)
Newer versions of Compose seem to skip adding a SetText semantic entirely when a TextField is readOnly = true or enabled = false, so we fallback to the EditableText semantic which seems to be always present.
1 parent 17041e9 commit 02931bf

File tree

3 files changed

+28
-2
lines changed

3 files changed

+28
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Fixes
1111

1212
- Session Replay: Fix masking of non-styled `Text` Composables ([#4361](https://github.com/getsentry/sentry-java/pull/4361))
13+
- Session Replay: Fix masking read-only `TextField` Composables ([#4362](https://github.com/getsentry/sentry-java/pull/4362))
1314

1415
## 8.10.0
1516

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ internal object ComposeViewHierarchyNode {
4141
return when {
4242
isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME
4343
collapsedSemantics?.contains(SemanticsProperties.Text) == true ||
44-
collapsedSemantics?.contains(SemanticsActions.SetText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME
44+
collapsedSemantics?.contains(SemanticsActions.SetText) == true ||
45+
collapsedSemantics?.contains(SemanticsProperties.EditableText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME
4546
else -> "android.view.View"
4647
}
4748
}
@@ -87,7 +88,8 @@ internal object ComposeViewHierarchyNode {
8788
val isVisible = !node.outerCoordinator.isTransparent() &&
8889
(semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) &&
8990
visibleRect.height() > 0 && visibleRect.width() > 0
90-
val isEditable = semantics?.contains(SemanticsActions.SetText) == true
91+
val isEditable = semantics?.contains(SemanticsActions.SetText) == true ||
92+
semantics?.contains(SemanticsProperties.EditableText) == true
9193
return when {
9294
semantics?.contains(SemanticsProperties.Text) == true || isEditable -> {
9395
val shouldMask = isVisible && node.shouldMask(isImage = false, options)

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ import androidx.compose.material3.TextField
1616
import androidx.compose.ui.Alignment
1717
import androidx.compose.ui.Modifier
1818
import androidx.compose.ui.platform.testTag
19+
import androidx.compose.ui.semantics.clearAndSetSemantics
20+
import androidx.compose.ui.semantics.editableText
1921
import androidx.compose.ui.semantics.invisibleToUser
2022
import androidx.compose.ui.semantics.semantics
23+
import androidx.compose.ui.text.AnnotatedString
2124
import androidx.compose.ui.text.input.TextFieldValue
2225
import androidx.compose.ui.unit.TextUnit
2326
import androidx.compose.ui.unit.dp
@@ -55,6 +58,7 @@ class ComposeMaskingOptionsTest {
5558
System.setProperty("robolectric.areWindowsMarkedVisible", "true")
5659
System.setProperty("robolectric.pixelCopyRenderMode", "hardware")
5760
ComposeMaskingOptionsActivity.textModifierApplier = null
61+
ComposeMaskingOptionsActivity.textFieldModifierApplier = null
5862
ComposeMaskingOptionsActivity.containerModifierApplier = null
5963
ComposeMaskingOptionsActivity.fontSizeApplier = null
6064
}
@@ -90,6 +94,23 @@ class ComposeMaskingOptionsTest {
9094
assertEquals("Random repo", (textNodes.first().layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text)
9195
}
9296

97+
@Test
98+
fun `when text input field is readOnly still masks it`() {
99+
ComposeMaskingOptionsActivity.textFieldModifierApplier = {
100+
// newer versions of compose basically do this when a TextField is readOnly
101+
Modifier.clearAndSetSemantics { editableText = AnnotatedString("Placeholder") }
102+
}
103+
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
104+
shadowOf(Looper.getMainLooper()).idle()
105+
106+
val options = SentryOptions().apply {
107+
sessionReplay.maskAllText = true
108+
}
109+
110+
val textNodes = activity.get().collectNodesOfType<TextViewHierarchyNode>(options)
111+
assertTrue(textNodes[1].shouldMask)
112+
}
113+
93114
@Test
94115
fun `when maskAllText is set to false all Text nodes are unmasked`() {
95116
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
@@ -231,6 +252,7 @@ private class ComposeMaskingOptionsActivity : ComponentActivity() {
231252

232253
companion object {
233254
var textModifierApplier: (() -> Modifier)? = null
255+
var textFieldModifierApplier: (() -> Modifier)? = null
234256
var containerModifierApplier: (() -> Modifier)? = null
235257
var fontSizeApplier: (() -> TextUnit)? = null
236258
}
@@ -254,6 +276,7 @@ private class ComposeMaskingOptionsActivity : ComponentActivity() {
254276
)
255277
Text("Random repo", fontSize = fontSizeApplier?.invoke() ?: TextUnit.Unspecified)
256278
TextField(
279+
modifier = textFieldModifierApplier?.invoke() ?: Modifier,
257280
value = TextFieldValue("Placeholder"),
258281
onValueChange = { _ -> }
259282
)

0 commit comments

Comments
 (0)