Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Compose works
  • Loading branch information
romtsn committed Sep 27, 2024
commit a3010b91a5ac2d1ed7b25460fd1c6ddcbb30afab
2 changes: 1 addition & 1 deletion sentry-android-replay/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ kotlin {
dependencies {
api(projects.sentry)

compileOnly("androidx.compose.ui:ui:1.4.0")
compileOnly("androidx.compose.ui:ui:1.5.0")
implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION))

// tests
Expand Down
19 changes: 11 additions & 8 deletions sentry-android-replay/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@
# debugging stack traces.
-keepattributes SourceFile,LineNumberTable

-dontwarn androidx.compose.ui.draw.PainterElement
-dontwarn androidx.compose.ui.draw.PainterModifierNodeElement
-dontwarn androidx.compose.ui.platform.AndroidComposeView
# Rules to detect Images/Icons and redact them
-dontwarn androidx.compose.ui.graphics.painter.Painter
#-dontwarn coil.compose.ContentPainterModifier
#-dontwarn coil3.compose.ContentPainterModifier
-keepnames class * extends androidx.compose.ui.graphics.painter.Painter
-keepclasseswithmembernames class * {
androidx.compose.ui.graphics.painter.Painter painter;
}
-keepnames class * extends androidx.compose.ui.graphics.painter.Painter
-keepnames class androidx.compose.ui.draw.PainterModifierNodeElement
-keepnames class androidx.compose.ui.draw.PainterElement
# Rules to detect Text colors and if they have Modifier.fillMaxWidth to later redact them
-dontwarn androidx.compose.ui.graphics.ColorProducer
-dontwarn androidx.compose.foundation.layout.FillElement
-keepnames class androidx.compose.foundation.layout.FillElement
-keepclasseswithmembernames class * {
androidx.compose.ui.graphics.ColorProducer color;
}
# Rules to detect a compose view to parse its hierarchy
-dontwarn androidx.compose.ui.platform.AndroidComposeView
-keepnames class androidx.compose.ui.platform.AndroidComposeView
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ internal class ScreenshotRecorder(
// next bind the new root
rootView = WeakReference(root)
root.viewTreeObserver?.addOnDrawListener(this)
// invalidate the flag to capture the first frame after new window is attached
contentChanged.set(true)
}

fun unbind(root: View?) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals and classes
package io.sentry.android.replay.util

import android.graphics.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorProducer
import androidx.compose.ui.graphics.painter.Painter
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 {
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
}
}
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 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)
}

/**
* This method is necessary to redact images in Compose.
*
* We heuristically look up for classes that have a [Painter] modifier, usually they all have a
* `Painter` string in their name, e.g. PainterElement, PainterModifierNodeElement or
* ContentPainterModifier for Coil.
*
* That's not going to cover all cases, but probably 90%.
*
* We also add special proguard rules to keep the `Painter` class names and their `painter` member.
*/
internal fun LayoutNode.findPainter(): Painter? {
val modifierInfos = getModifierInfo()
for (index in modifierInfos.indices) {
val modifier = modifierInfos[index].modifier
if (modifier::class.java.name.contains("Painter")) {
return try {
modifier::class.java.getDeclaredField("painter")
.apply { isAccessible = true }
.get(modifier) as? Painter
} catch (e: Throwable) {
null
}
}
}
return null
}

/**
* We heuristically check the known classes that are coming from local assets usually:
* [androidx.compose.ui.graphics.vector.VectorPainter]
* [androidx.compose.ui.graphics.painter.ColorPainter]
* [androidx.compose.ui.graphics.painter.BrushPainter]
*
* In theory, [androidx.compose.ui.graphics.painter.BitmapPainter] can also come from local assets,
* but it can as well come from a network resource, so we preemptively redact it.
*/
internal fun Painter.isRedactable(): Boolean {
val className = this::class.java.name
return !className.contains("Vector") &&
!className.contains("Color") &&
!className.contains("Brush")
}

/**
* Converts from [androidx.compose.ui.geometry.Rect] to [android.graphics.Rect].
*/
internal fun androidx.compose.ui.geometry.Rect.toRect(): Rect {
return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
}

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

internal fun LayoutNode.findTextAttributes(): TextAttributes {
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 TextAttributes(color, hasFillModifier)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.sentry.android.replay.util

/**
* An abstraction over [android.text.Layout] with different implementations for Views and Compose.
*/
interface TextLayout {
val lineCount: Int
/**
* Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if
* this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it
* returns null.
*/
val dominantTextColor: Int?
fun getPrimaryHorizontal(line: Int, offset: Int): Float
fun getEllipsisCount(line: Int): Int
fun getLineVisibleEnd(line: Int): Int
fun getLineTop(line: Int): Int
fun getLineBottom(line: Int): Int
fun getLineStart(line: Int): Int
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,7 @@ import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.view.View
import android.widget.TextView
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.TextLayoutResult
import io.sentry.SentryOptions
import io.sentry.android.replay.R
import java.lang.NullPointerException
import kotlin.math.roundToInt

/**
* Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718
Expand Down Expand Up @@ -77,12 +72,13 @@ internal fun TextLayout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, pad

val rects = mutableListOf<Rect>()
for (i in 0 until lineCount) {
val lineStart = getPrimaryHorizontal(getLineStart(i)).toInt()
val lineStart = getPrimaryHorizontal(i, getLineStart(i)).toInt()
val ellipsisCount = getEllipsisCount(i)
var lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt()
if (lineEnd == 0) {
val lineVisibleEnd = getLineVisibleEnd(i)
var lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt()
if (lineEnd == 0 && lineVisibleEnd > 0) {
// looks like the case for when emojis are present in text
lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - 1).toInt() + 1
lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - 1).toInt() + 1
}
val lineTop = getLineTop(i)
val lineBottom = getLineBottom(i)
Expand Down Expand Up @@ -114,22 +110,6 @@ internal val TextView.totalPaddingTopSafe: Int
*/
internal fun Int.toOpaque() = this or 0xFF000000.toInt()

interface TextLayout {
val lineCount: Int
/**
* Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if
* this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it
* returns null.
*/
val dominantTextColor: Int?
fun getPrimaryHorizontal(offset: Int): Float
fun getEllipsisCount(line: Int): Int
fun getLineVisibleEnd(line: Int): Int
fun getLineTop(line: Int): Int
fun getLineBottom(line: Int): Int
fun getLineStart(line: Int): Int
}

class AndroidTextLayout(private val layout: Layout) : TextLayout {
override val lineCount: Int get() = layout.lineCount
override val dominantTextColor: Int? get() {
Expand All @@ -155,21 +135,10 @@ class AndroidTextLayout(private val layout: Layout) : TextLayout {
}
return dominantColor?.toOpaque()
}
override fun getPrimaryHorizontal(offset: Int): Float = layout.getPrimaryHorizontal(offset)
override fun getPrimaryHorizontal(line: Int, offset: Int): Float = layout.getPrimaryHorizontal(offset)
override fun getEllipsisCount(line: Int): Int = layout.getEllipsisCount(line)
override fun getLineVisibleEnd(line: Int): Int = layout.getLineVisibleEnd(line)
override fun getLineTop(line: Int): Int = layout.getLineTop(line)
override fun getLineBottom(line: Int): Int = layout.getLineBottom(line)
override fun getLineStart(line: Int): Int = layout.getLineStart(line)
}

class ComposeTextLayout(internal val layout: TextLayoutResult) : TextLayout {
override val lineCount: Int get() = layout.lineCount
override val dominantTextColor: Int get() = layout.layoutInput.style.color.toArgb().toOpaque()
override fun getPrimaryHorizontal(offset: Int): Float = layout.getHorizontalPosition(offset, usePrimaryDirection = true)
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 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)
}
Loading