Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixes

- Session Replay: Fix Compose text masking mismatch with weighted text ([#5218](https://github.com/getsentry/sentry-java/pull/5218))

### Features

- Android: Add `beforeErrorSampling` callback to Session Replay ([#5214](https://github.com/getsentry/sentry-java/pull/5214))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,27 @@ import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Looper
import android.text.TextUtils
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams
import android.widget.RadioButton
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.dropbox.differ.Color as DifferColor
import com.dropbox.differ.Image
Expand Down Expand Up @@ -410,6 +425,48 @@ class ScreenshotEventProcessorTest {
assertNotNull(bytes)
}

@Test
fun `snapshot - screenshot with ellipsized text no masking`() {
fixture.activity = buildActivity(EllipsizedTextActivity::class.java, null).setup().get()
val bytes =
processEventForSnapshots(
"screenshot_mask_ellipsized_view_unmasked",
isReplayAvailable = false,
)
assertNotNull(bytes)
}

@Test
fun `snapshot - screenshot with ellipsized text masking`() {
fixture.activity = buildActivity(EllipsizedTextActivity::class.java, null).setup().get()
val bytes =
processEventForSnapshots("screenshot_mask_ellipsized_view_masked") {
it.screenshot.setMaskAllText(true)
}
assertNotNull(bytes)
}

@Test
fun `snapshot - compose text no masking`() {
fixture.activity = buildActivity(ComposeTextActivity::class.java, null).setup().get()
val bytes =
processEventForSnapshots(
"screenshot_mask_ellipsized_compose_unmasked",
isReplayAvailable = false,
)
assertNotNull(bytes)
}

@Test
fun `snapshot - compose text with masking`() {
fixture.activity = buildActivity(ComposeTextActivity::class.java, null).setup().get()
val bytes =
processEventForSnapshots("screenshot_mask_ellipsized_compose_masked") {
it.screenshot.setMaskAllText(true)
}
assertNotNull(bytes)
}

// endregion

private fun getEvent(): SentryEvent = SentryEvent(Throwable("Throwable"))
Expand Down Expand Up @@ -484,6 +541,189 @@ private class CustomView(context: Context) : View(context) {
}
}

private class EllipsizedTextActivity : Activity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val longText = "This is a very long text that should be ellipsized when it does not fit"

val linearLayout =
LinearLayout(this).apply {
setBackgroundColor(Color.WHITE)
orientation = LinearLayout.VERTICAL
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
setPadding(10, 10, 10, 10)
}

// Ellipsize end
linearLayout.addView(
TextView(this).apply {
text = longText
setTextColor(Color.BLACK)
textSize = 16f
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setBackgroundColor(Color.LTGRAY)
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 8, 0, 0)
}
}
)

// Ellipsize middle
linearLayout.addView(
TextView(this).apply {
text = longText
setTextColor(Color.BLACK)
textSize = 16f
maxLines = 1
ellipsize = TextUtils.TruncateAt.MIDDLE
setBackgroundColor(Color.LTGRAY)
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 8, 0, 0)
}
}
)

// Ellipsize start
linearLayout.addView(
TextView(this).apply {
text = longText
setTextColor(Color.BLACK)
textSize = 16f
maxLines = 1
ellipsize = TextUtils.TruncateAt.START
setBackgroundColor(Color.LTGRAY)
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 8, 0, 0)
}
}
)

// Non-ellipsized text for comparison
linearLayout.addView(
TextView(this).apply {
text = "Short text"
setTextColor(Color.BLACK)
textSize = 16f
setBackgroundColor(Color.LTGRAY)
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 8, 0, 0)
}
}
)

setContentView(linearLayout)
}
}

private class ComposeTextActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val longText = "This is a very long text that should be ellipsized when it does not fit in view"

setContent {
Column(
modifier =
Modifier.fillMaxWidth()
.background(androidx.compose.ui.graphics.Color.White)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Ellipsis overflow
Text(
longText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 16.sp,
modifier =
Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray),
)

// Text with textAlign center
Text(
"Centered text",
textAlign = TextAlign.Center,
fontSize = 16.sp,
modifier =
Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray),
)

// Text with textAlign end
Text(
"End-aligned text",
textAlign = TextAlign.End,
fontSize = 16.sp,
modifier =
Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray),
)

// Ellipsis with textAlign center
Text(
longText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
fontSize = 16.sp,
modifier =
Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray),
)

// Weighted row with text
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Weight 1",
fontSize = 16.sp,
modifier = Modifier.weight(1f).background(androidx.compose.ui.graphics.Color.LightGray),
)
Text(
"Weight 1",
fontSize = 16.sp,
modifier = Modifier.weight(1f).background(androidx.compose.ui.graphics.Color.LightGray),
)
}

// Weighted row with ellipsized text
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
longText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 16.sp,
modifier = Modifier.weight(1f).background(androidx.compose.ui.graphics.Color.LightGray),
)
Text(
longText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
fontSize = 16.sp,
modifier = Modifier.weight(1f).background(androidx.compose.ui.graphics.Color.LightGray),
)
}

// Short text (for comparison)
Text(
"Short text",
fontSize = 16.sp,
modifier = Modifier.background(androidx.compose.ui.graphics.Color.LightGray),
)
}
}
}
}

private class MaskingActivity : Activity() {

override fun onCreate(savedInstanceState: Bundle?) {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -13,38 +13,39 @@ import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.text.TextLayoutResult
import kotlin.math.roundToInt

internal class ComposeTextLayout(
internal val layout: TextLayoutResult,
private val hasFillModifier: Boolean,
) : TextLayout {
internal class ComposeTextLayout(internal val layout: TextLayoutResult) : TextLayout {
override val lineCount: Int
get() = layout.lineCount

override val dominantTextColor: Int?
get() = null

override fun getPrimaryHorizontal(line: Int, offset: Int): Float {
val horizontalPos = layout.getHorizontalPosition(offset, usePrimaryDirection = true)
// when there's no `fill` modifier on a Text composable, compose still thinks that there's
// one and wrongly calculates horizontal position relative to node's start, not text's start
// for some reason. This is only the case for single-line text (multiline works fien).
// So we subtract line's left to get the correct position
return if (!hasFillModifier && lineCount == 1) {
horizontalPos - layout.getLineLeft(line)
} else {
horizontalPos
/**
* The paragraph may be laid out with a wider width (constraint maxWidth) than the actual node
* (layout result size). When that happens, getLineLeft/getLineRight return positions in the
* paragraph coordinate system, which don't match the node's bounds. In that case, text alignment
* has no visible effect, so we fall back to using line width starting from x=0.
*/
private val paragraphWidthExceedsNode: Boolean
get() = layout.multiParagraph.width > layout.size.width

override fun getLineLeft(line: Int): Float {
if (paragraphWidthExceedsNode) {
return 0f
}
return layout.getLineLeft(line)
}

override fun getEllipsisCount(line: Int): Int = if (layout.isLineEllipsized(line)) 1 else 0

override fun getLineVisibleEnd(line: Int): Int = layout.getLineEnd(line, visibleEnd = true)
override fun getLineRight(line: Int): Float {
if (paragraphWidthExceedsNode) {
return layout.multiParagraph.getLineWidth(line)
}
return layout.getLineRight(line)
}

override fun getLineTop(line: Int): Int = layout.getLineTop(line).roundToInt()

override fun getLineBottom(line: Int): Int = layout.getLineBottom(line).roundToInt()

override fun getLineStart(line: Int): Int = layout.getLineStart(line)
}

// TODO: probably most of the below we can do via bytecode instrumentation and speed up at runtime
Expand Down Expand Up @@ -92,46 +93,31 @@ internal fun Painter.isMaskable(): Boolean {
!className.contains("Brush")
}

internal data class TextAttributes(val color: Color?, val hasFillModifier: Boolean)

/**
* This method is necessary to mask text in Compose.
*
* We heuristically look up for classes that have a [Text] modifier, usually they all have a `Text`
* string in their name, e.g. TextStringSimpleElement or TextAnnotatedStringElement. We then get the
* color from the modifier, to be able to mask it with the correct color.
*
* We also look up for classes that have a [Fill] modifier, usually they all have a `Fill` string in
* their name, e.g. FillElement. This is necessary to workaround a Compose bug where single-line
* text composable without a `fill` modifier still thinks that there's one and wrongly calculates
* horizontal position.
*
* We also add special proguard rules to keep the `Text` class names and their `color` member.
*/
internal fun LayoutNode.findTextAttributes(): TextAttributes {
internal fun LayoutNode.findTextColor(): Color? {
val modifierInfos = getModifierInfo()
var color: Color? = null
var hasFillModifier = false
for (index in modifierInfos.indices) {
val modifier = modifierInfos[index].modifier
val modifierClassName = modifier::class.java.name
if (modifierClassName.contains("Text")) {
color =
try {
(modifier::class
.java
.getDeclaredField("color")
.apply { isAccessible = true }
.get(modifier) as? ColorProducer)
?.invoke()
} catch (e: Throwable) {
null
}
} else if (modifierClassName.contains("Fill")) {
hasFillModifier = true
return try {
(modifier::class.java.getDeclaredField("color").apply { isAccessible = true }.get(modifier)
as? ColorProducer)
?.invoke()
} catch (e: Throwable) {
null
}
}
}
return TextAttributes(color, hasFillModifier)
return null
}

/**
Expand Down
Loading
Loading