From d1b1ee9025d9b98aa92763220650c426f0bc3b97 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 25 Sep 2024 23:07:38 +0200 Subject: [PATCH 1/9] WIP --- sentry-android-replay/build.gradle.kts | 10 +- sentry-android-replay/proguard-rules.pro | 14 ++ .../android/replay/ScreenshotRecorder.kt | 24 +- .../io/sentry/android/replay/util/Views.kt | 85 +++++-- .../viewhierarchy/ComposeViewHierarchyNode.kt | 214 ++++++++++++++++++ .../replay/viewhierarchy/ViewHierarchyNode.kt | 78 +++---- .../sentry-samples-android/build.gradle.kts | 1 + .../src/main/AndroidManifest.xml | 2 +- .../android/compose/ComposeActivity.kt | 20 ++ .../main/res/drawable/logo_pocket_casts.xml | 50 ++++ sentry/src/main/java/io/sentry/ReplayApi.java | 41 ++++ sentry/src/main/java/io/sentry/Sentry.java | 7 + 12 files changed, 477 insertions(+), 69 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/res/drawable/logo_pocket_casts.xml create mode 100644 sentry/src/main/java/io/sentry/ReplayApi.java diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 2e74641268..78749c2b6a 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { id("com.android.library") @@ -27,7 +28,9 @@ android { buildTypes { getByName("debug") - getByName("release") + getByName("release") { + consumerProguardFiles("proguard-rules.pro") + } } kotlinOptions { @@ -65,6 +68,7 @@ kotlin { dependencies { api(projects.sentry) + compileOnly("androidx.compose.ui:ui:1.4.0") implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) // tests @@ -83,3 +87,7 @@ tasks.withType { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() } + +tasks.withType>().configureEach { + compilerOptions.freeCompilerArgs.add("-opt-in=androidx.compose.ui.ExperimentalComposeUiApi") +} diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index 738204b4c8..dac15df342 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -1,3 +1,17 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -keepattributes SourceFile,LineNumberTable + +-dontwarn androidx.compose.ui.draw.PainterElement +-dontwarn androidx.compose.ui.draw.PainterModifierNodeElement +-dontwarn androidx.compose.ui.platform.AndroidComposeView +-dontwarn androidx.compose.ui.graphics.painter.Painter +#-dontwarn coil.compose.ContentPainterModifier +#-dontwarn coil3.compose.ContentPainterModifier +-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 +-keepnames class androidx.compose.ui.platform.AndroidComposeView diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index fdab9f442d..7e52de0c50 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -13,6 +13,7 @@ import android.graphics.Rect import android.graphics.RectF import android.os.Build.VERSION import android.os.Build.VERSION_CODES +import android.util.Log import android.view.PixelCopy import android.view.View import android.view.ViewGroup @@ -24,10 +25,10 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.android.replay.util.MainLooperHandler -import io.sentry.android.replay.util.dominantTextColor import io.sentry.android.replay.util.getVisibleRects import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely +import io.sentry.android.replay.viewhierarchy.ComposeViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -38,6 +39,7 @@ import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt +import kotlin.system.measureNanoTime @TargetApi(26) internal class ScreenshotRecorder( @@ -115,6 +117,7 @@ internal class ScreenshotRecorder( return@request } + // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture) if (contentChanged.get()) { options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") bitmap.recycle() @@ -132,9 +135,9 @@ internal class ScreenshotRecorder( node.visibleRect ?: return@traverse false // TODO: investigate why it returns true on RN when it shouldn't -// if (viewHierarchy.isObscured(node)) { -// return@traverse true -// } + if (viewHierarchy.isObscured(node)) { + return@traverse true + } val (visibleRects, color) = when (node) { is ImageViewHierarchyNode -> { @@ -143,7 +146,7 @@ internal class ScreenshotRecorder( } is TextViewHierarchyNode -> { - val textColor = node.layout.dominantTextColor + val textColor = node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK node.layout.getVisibleRects( @@ -256,6 +259,17 @@ internal class ScreenshotRecorder( return } + var isCompose: Boolean + val time = measureNanoTime { + isCompose = ComposeViewHierarchyNode.fromView(this, parentNode, options) + } + if (isCompose) { + Log.e("TIME", String.format("%.2f", time / 1_000_000.0) + "ms") + // if it's a compose view, we can skip the children as they are already traversed in + // the ComposeViewHierarchyNode.fromView method + return + } + if (this.childCount == 0) { return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index 86c75f2e9d..abca9cfa3f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -17,7 +17,12 @@ 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 @@ -65,7 +70,7 @@ internal fun Drawable?.isRedactable(): Boolean { } } -internal fun Layout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { +internal fun TextLayout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { if (this == null) { return listOf(globalRect) } @@ -105,32 +110,66 @@ internal val TextView.totalPaddingTopSafe: 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. + * Converts an [Int] ARGB color to an opaque color by setting the alpha channel to 255. */ -internal val Layout?.dominantTextColor: Int? get() { - this ?: return null +internal fun Int.toOpaque() = this or 0xFF000000.toInt() - if (text !is Spanned) return null +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 +} - val spans = (text as Spanned).getSpans(0, text.length, ForegroundColorSpan::class.java) +class AndroidTextLayout(private val layout: Layout) : TextLayout { + override val lineCount: Int get() = layout.lineCount + override val dominantTextColor: Int? get() { + if (layout.text !is Spanned) return null - // determine the dominant color by the span with the longest range - var longestSpan = Int.MIN_VALUE - var dominantColor: Int? = null - for (span in spans) { - val spanStart = (text as Spanned).getSpanStart(span) - val spanEnd = (text as Spanned).getSpanEnd(span) - if (spanStart == -1 || spanEnd == -1) { - // the span is not attached - continue - } - val spanLength = spanEnd - spanStart - if (spanLength > longestSpan) { - longestSpan = spanLength - dominantColor = span.foregroundColor + val spans = (layout.text as Spanned).getSpans(0, layout.text.length, ForegroundColorSpan::class.java) + + // determine the dominant color by the span with the longest range + var longestSpan = Int.MIN_VALUE + var dominantColor: Int? = null + for (span in spans) { + val spanStart = (layout.text as Spanned).getSpanStart(span) + val spanEnd = (layout.text as Spanned).getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) { + // the span is not attached + continue + } + val spanLength = spanEnd - spanStart + if (spanLength > longestSpan) { + longestSpan = spanLength + dominantColor = span.foregroundColor + } } + return dominantColor?.toOpaque() } - return dominantColor + override fun getPrimaryHorizontal(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) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt new file mode 100644 index 0000000000..e6fb17f8c0 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -0,0 +1,214 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals +package io.sentry.android.replay.viewhierarchy + +import android.annotation.TargetApi +import android.graphics.Rect +import android.view.View +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsOwner +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.text.TextLayoutResult +import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode + +@TargetApi(26) +internal object ComposeViewHierarchyNode { + + /** + * Since Compose doesn't have a concept of a View class (they are all composable functions), + * we need to map the semantics node to a corresponding old view system class. + */ + private fun SemanticsNode?.getProxyClassName(isImage: Boolean): String { + return when { + isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME + this != null && (unmergedConfig.contains(SemanticsProperties.Text) || + unmergedConfig.contains(SemanticsActions.SetText)) -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME + else -> "android.view.View" + } + } + + private fun SemanticsNode?.shouldRedact(isImage: Boolean, options: SentryOptions): Boolean { + val className = getProxyClassName(isImage) + if (options.experimental.sessionReplay.ignoreViewClasses.contains(className)) { + return false + } + + return options.experimental.sessionReplay.redactViewClasses.contains(className) + } + + private fun LayoutNode.findPainter(): Painter? { + val modifierInfos = getModifierInfo() + for (modifierInfo in modifierInfos) { + val modifier = modifierInfo.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 + } + + private fun Painter.isRedactable(): Boolean { + val className = this::class.java.name + return !className.contains("Vector") && + !className.contains("Color") && + !className.contains("Brush") + } + + private fun androidx.compose.ui.geometry.Rect.toRect(): Rect { + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + } + + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, semanticsNodes: Map, options: SentryOptions) { + val children = this.children + if (children.isEmpty()) { + return + } + + val childNodes = ArrayList(children.size) + for (index in children.indices) { + val child = children[index] + val semanticsNode = semanticsNodes[child.semanticsId] + val childNode = fromComposeNode(child, semanticsNode, parentNode, child.depth, options) + if (childNode != null) { + childNodes.add(childNode) + child.traverse(childNode, semanticsNodes, options) + } + } + parentNode.children = childNodes + } + + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + private fun fromComposeNode( + node: LayoutNode, + semanticsNode: SemanticsNode?, + parent: ViewHierarchyNode?, + distance: Int, + options: SentryOptions + ): ViewHierarchyNode? { + val isInTree = node.isPlaced && node.isAttached + if (!isInTree) { + return null + } + val isVisible = semanticsNode == null || (!semanticsNode.isTransparent && !semanticsNode.unmergedConfig.contains(SemanticsProperties.InvisibleToUser)) + val painter: Painter? = node.findPainter() + val shouldRedact = isVisible && semanticsNode.shouldRedact(painter != null, options) + val isEditable = semanticsNode?.unmergedConfig?.contains(SemanticsActions.SetText) == true + val positionInWindow = node.coordinates.positionInWindow() + val boundsInWindow = node.coordinates.boundsInWindow() + when { + semanticsNode?.unmergedConfig?.contains(SemanticsProperties.Text) == true || isEditable -> { + parent?.setImportantForCaptureToAncestors(true) + val textLayoutResults = mutableListOf() + semanticsNode?.unmergedConfig?.getOrNull(SemanticsActions.GetTextLayoutResult) + ?.action + ?.invoke(textLayoutResults) + // TODO: support multiple text layouts + // TODO: support editable text (currently there's no way to get @Composable's padding, and we can't reliably mask input fields based on TextLayout, so we mask the whole view instead) + return TextViewHierarchyNode( + layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first()) else null, + dominantColor = textLayoutResults.firstOrNull()?.layoutInput?.style?.color?.toArgb()?.toOpaque(), + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldRedact = shouldRedact, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = boundsInWindow.toRect() + ) + } + painter != null -> { + parent?.setImportantForCaptureToAncestors(true) + return ImageViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + isVisible = isVisible, + isImportantForContentCapture = true, + shouldRedact = shouldRedact && painter.isRedactable(), + visibleRect = boundsInWindow.toRect() + ) + } + } + + return GenericViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldRedact = shouldRedact, // TODO: use custom modifier to mark views that should be redacted/ignored + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = boundsInWindow.toRect() + ) + } + + fun fromView(view: View, parent: ViewHierarchyNode?, options: SentryOptions): Boolean { + if (!view::class.java.name.contains("AndroidComposeView")) { + return false + } + + if (parent == null) { + return false + } + + val semanticsNodes = (view as? RootForTest)?.semanticsOwner?.getAllSemanticsNodesToMap(true) ?: return false + val rootNode = (view as? Owner)?.root ?: return false + rootNode.traverse(parent, semanticsNodes, options) + return true + } + + /** + * Backport of https://github.com/androidx/androidx/blob/d0b13cd790006c94a2665474a91e465af4beb094/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt#L81-L100 + * which got changed in newer versions + */ + private fun SemanticsOwner.getAllSemanticsNodesToMap( + useUnmergedTree: Boolean = false, + ): Map { + val nodes = mutableMapOf() + + fun findAllSemanticNodesRecursive(currentNode: SemanticsNode) { + nodes[currentNode.id] = currentNode + val children = currentNode.children + for (index in children.indices) { + val node = children[index] + findAllSemanticNodesRecursive(node) + } + } + + val root = if (useUnmergedTree) unmergedRootSemanticsNode else rootSemanticsNode + findAllSemanticNodesRecursive(root) + return nodes + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 90b96f134b..7191df888b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -8,8 +8,11 @@ import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions import io.sentry.android.replay.R +import io.sentry.android.replay.util.AndroidTextLayout +import io.sentry.android.replay.util.TextLayout import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser +import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.totalPaddingTopSafe @TargetApi(26) @@ -46,7 +49,7 @@ sealed class ViewHierarchyNode( ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) class TextViewHierarchyNode( - val layout: Layout? = null, + val layout: TextLayout? = null, val dominantColor: Int? = null, val paddingLeft: Int = 0, val paddingTop: Int = 0, @@ -77,6 +80,20 @@ sealed class ViewHierarchyNode( visibleRect: Rect? = null ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + /** + * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() + * but for lower APIs and with less overhead. If we take a look at how it's set in Android: + * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain + * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. + */ + fun setImportantForCaptureToAncestors(isImportant: Boolean) { + var parent = this.parent + while (parent != null) { + parent.isImportantForContentCapture = isImportant + parent = parent.parent + } + } + /** * Traverses the view hierarchy starting from this node. The traversal is done in a depth-first * manner. @@ -217,23 +234,6 @@ sealed class ViewHierarchyNode( ) companion object { - - private fun Int.toOpaque() = this or 0xFF000000.toInt() - - /** - * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() - * but for lower APIs and with less overhead. If we take a look at how it's set in Android: - * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain - * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. - */ - private fun ViewHierarchyNode?.setImportantForCaptureToAncestors(isImportant: Boolean) { - var parent = this?.parent - while (parent != null) { - parent.isImportantForContentCapture = isImportant - parent = parent.parent - } - } - private const val SENTRY_IGNORE_TAG = "sentry-ignore" private const val SENTRY_REDACT_TAG = "sentry-redact" @@ -273,29 +273,29 @@ sealed class ViewHierarchyNode( val (isVisible, visibleRect) = view.isVisibleToUser() val shouldRedact = isVisible && view.shouldRedact(options) when (view) { - is TextView -> { - parent.setImportantForCaptureToAncestors(true) - return TextViewHierarchyNode( - layout = view.layout, - dominantColor = view.currentTextColor.toOpaque(), - paddingLeft = view.totalPaddingLeft, - paddingTop = view.totalPaddingTopSafe, - x = view.x, - y = view.y, - width = view.width, - height = view.height, - elevation = (parent?.elevation ?: 0f) + view.elevation, - shouldRedact = shouldRedact, - distance = distance, - parent = parent, - isImportantForContentCapture = true, - isVisible = isVisible, - visibleRect = visibleRect - ) - } + is TextView -> { + parent?.setImportantForCaptureToAncestors(true) + return TextViewHierarchyNode( + layout = view.layout?.let { AndroidTextLayout(it) }, + dominantColor = view.currentTextColor.toOpaque(), + paddingLeft = view.totalPaddingLeft, + paddingTop = view.totalPaddingTopSafe, + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + shouldRedact = shouldRedact, + distance = distance, + parent = parent, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect + ) + } is ImageView -> { - parent.setImportantForCaptureToAncestors(true) + parent?.setImportantForCaptureToAncestors(true) return ImageViewHierarchyNode( x = view.x, y = view.y, diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index a8d8897519..8e5f918241 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -127,6 +127,7 @@ dependencies { implementation(Config.Libs.retrofit2) implementation(Config.Libs.retrofit2Gson) + implementation("io.coil-kt:coil-compose:2.6.0") implementation(Config.Libs.composeActivity) implementation(Config.Libs.composeFoundation) implementation(Config.Libs.composeFoundationLayout) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 8876efd66d..703685d6f0 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -166,7 +166,7 @@ - + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 1a4929b0b7..62755bcd81 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -5,10 +5,13 @@ package io.sentry.samples.android.compose import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -22,7 +25,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -31,10 +38,12 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import coil.compose.AsyncImage import io.sentry.compose.SentryTraced import io.sentry.compose.withSentryObservableEffect import io.sentry.samples.android.GithubAPI import kotlinx.coroutines.launch +import io.sentry.samples.android.R as IR class ComposeActivity : ComponentActivity() { @@ -109,6 +118,17 @@ fun Github( modifier = Modifier .fillMaxSize() ) { + Image( + painter = painterResource(IR.drawable.logo_pocket_casts), + contentDescription = "LOGO", + colorFilter = ColorFilter.tint(Color.Black), + modifier = Modifier.padding(vertical = 16.dp) + ) + AsyncImage( + model = "https://i.imgur.com/tie6A3J.jpeg", + contentDescription = "IMG", + modifier = Modifier.padding(vertical = 16.dp) + ) TextField( value = user, onValueChange = { newText -> diff --git a/sentry-samples/sentry-samples-android/src/main/res/drawable/logo_pocket_casts.xml b/sentry-samples/sentry-samples-android/src/main/res/drawable/logo_pocket_casts.xml new file mode 100644 index 0000000000..1003ee7d0f --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/drawable/logo_pocket_casts.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + diff --git a/sentry/src/main/java/io/sentry/ReplayApi.java b/sentry/src/main/java/io/sentry/ReplayApi.java new file mode 100644 index 0000000000..37b46662f6 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayApi.java @@ -0,0 +1,41 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; + +public final class ReplayApi { + private final @NotNull ReplayController replayController; + + public ReplayApi(final @NotNull ReplayController replayController) { + this.replayController = replayController; + } + + /** + * Resumes screen recording if it was paused. + */ + public void resume() { + replayController.resume(); + } + + /** + * Pauses screen recording entirely, but does not stop the current replay. + */ + public void pause() { + replayController.pause(); + } + + /** + * Returns whether the replay is currently running + */ + public boolean isRecording() { + return replayController.isRecording(); + } + + /** + * The id of the currently running replay or {@link SentryId#EMPTY_ID} if no replay is running + */ + @NotNull + public SentryId getReplayId() { + return replayController.getReplayId(); + } +} diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 08571e151a..18359e6150 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -985,6 +985,13 @@ public static MetricsApi metrics() { return getCurrentHub().metrics(); } + /** the replay API for the current hub */ + @NotNull + @ApiStatus.Experimental + public static ReplayApi replay() { + return new ReplayApi(getCurrentHub().getOptions().getReplayController()); + } + /** * Configuration options callback * From a3010b91a5ac2d1ed7b25460fd1c6ddcbb30afab Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Sat, 28 Sep 2024 00:57:49 +0200 Subject: [PATCH 2/9] Compose works --- sentry-android-replay/build.gradle.kts | 2 +- sentry-android-replay/proguard-rules.pro | 19 +- .../android/replay/ScreenshotRecorder.kt | 2 + .../io/sentry/android/replay/util/Nodes.kt | 108 ++++++++ .../sentry/android/replay/util/TextLayout.kt | 20 ++ .../io/sentry/android/replay/util/Views.kt | 43 +--- .../viewhierarchy/ComposeViewHierarchyNode.kt | 231 ++++++++---------- 7 files changed, 250 insertions(+), 175 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 78749c2b6a..fb03d169f4 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -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 diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index dac15df342..445c89b526 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -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 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 7e52de0c50..ea658f3e00 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -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?) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt new file mode 100644 index 0000000000..c3461d936a --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -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) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt new file mode 100644 index 0000000000..a26aa7cd52 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt @@ -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 +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index abca9cfa3f..f6a9b2f322 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -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 @@ -77,12 +72,13 @@ internal fun TextLayout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, pad val rects = mutableListOf() 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) @@ -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() { @@ -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) -} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index e6fb17f8c0..6bfdd0df5e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -2,25 +2,26 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi -import android.graphics.Rect import android.view.View -import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.isUnspecified import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.node.RootForTest import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.node.Owner import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.semantics.SemanticsNode -import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.text.TextLayoutResult +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.findPainter +import io.sentry.android.replay.util.findTextAttributes +import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.util.toRect import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -32,16 +33,16 @@ internal object ComposeViewHierarchyNode { * Since Compose doesn't have a concept of a View class (they are all composable functions), * we need to map the semantics node to a corresponding old view system class. */ - private fun SemanticsNode?.getProxyClassName(isImage: Boolean): String { - return when { - isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME - this != null && (unmergedConfig.contains(SemanticsProperties.Text) || - unmergedConfig.contains(SemanticsActions.SetText)) -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME - else -> "android.view.View" - } - } - - private fun SemanticsNode?.shouldRedact(isImage: Boolean, options: SentryOptions): Boolean { + private fun LayoutNode.getProxyClassName(isImage: Boolean): String { + return when { + isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME + collapsedSemantics?.contains(SemanticsProperties.Text) == true || + collapsedSemantics?.contains(SemanticsActions.SetText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME + else -> "android.view.View" + } + } + + private fun LayoutNode.shouldRedact(isImage: Boolean, options: SentryOptions): Boolean { val className = getProxyClassName(isImage) if (options.experimental.sessionReplay.ignoreViewClasses.contains(className)) { return false @@ -50,58 +51,8 @@ internal object ComposeViewHierarchyNode { return options.experimental.sessionReplay.redactViewClasses.contains(className) } - private fun LayoutNode.findPainter(): Painter? { - val modifierInfos = getModifierInfo() - for (modifierInfo in modifierInfos) { - val modifier = modifierInfo.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 - } - - private fun Painter.isRedactable(): Boolean { - val className = this::class.java.name - return !className.contains("Vector") && - !className.contains("Color") && - !className.contains("Brush") - } - - private fun androidx.compose.ui.geometry.Rect.toRect(): Rect { - return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) - } - - @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals - private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, semanticsNodes: Map, options: SentryOptions) { - val children = this.children - if (children.isEmpty()) { - return - } - - val childNodes = ArrayList(children.size) - for (index in children.indices) { - val child = children[index] - val semanticsNode = semanticsNodes[child.semanticsId] - val childNode = fromComposeNode(child, semanticsNode, parentNode, child.depth, options) - if (childNode != null) { - childNodes.add(childNode) - child.traverse(childNode, semanticsNodes, options) - } - } - parentNode.children = childNodes - } - - @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals private fun fromComposeNode( node: LayoutNode, - semanticsNode: SemanticsNode?, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions @@ -110,24 +61,34 @@ internal object ComposeViewHierarchyNode { if (!isInTree) { return null } - val isVisible = semanticsNode == null || (!semanticsNode.isTransparent && !semanticsNode.unmergedConfig.contains(SemanticsProperties.InvisibleToUser)) - val painter: Painter? = node.findPainter() - val shouldRedact = isVisible && semanticsNode.shouldRedact(painter != null, options) - val isEditable = semanticsNode?.unmergedConfig?.contains(SemanticsActions.SetText) == true + + val semantics = node.collapsedSemantics + val visibleRect = node.coordinates.boundsInWindow().toRect() + val isVisible = !node.outerCoordinator.isTransparent() && + (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && + visibleRect.height() > 0 && visibleRect.width() > 0 + val isEditable = semantics?.contains(SemanticsActions.SetText) == true val positionInWindow = node.coordinates.positionInWindow() - val boundsInWindow = node.coordinates.boundsInWindow() - when { - semanticsNode?.unmergedConfig?.contains(SemanticsProperties.Text) == true || isEditable -> { + return when { + semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { + val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + parent?.setImportantForCaptureToAncestors(true) val textLayoutResults = mutableListOf() - semanticsNode?.unmergedConfig?.getOrNull(SemanticsActions.GetTextLayoutResult) + semantics?.getOrNull(SemanticsActions.GetTextLayoutResult) ?.action ?.invoke(textLayoutResults) + + val (color, hasFillModifier) = node.findTextAttributes() + var textColor = textLayoutResults.firstOrNull()?.layoutInput?.style?.color + if (textColor?.isUnspecified == true) { + textColor = color + } // TODO: support multiple text layouts - // TODO: support editable text (currently there's no way to get @Composable's padding, and we can't reliably mask input fields based on TextLayout, so we mask the whole view instead) - return TextViewHierarchyNode( - layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first()) else null, - dominantColor = textLayoutResults.firstOrNull()?.layoutInput?.style?.color?.toArgb()?.toOpaque(), + // TODO: support editable text (currently there's a way to get @Composable's padding only via reflection, and we can't reliably mask input fields based on TextLayout, so we mask the whole view instead) + TextViewHierarchyNode( + layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first(), hasFillModifier) else null, + dominantColor = textColor?.toArgb()?.toOpaque(), x = positionInWindow.x, y = positionInWindow.y, width = node.width, @@ -138,40 +99,47 @@ internal object ComposeViewHierarchyNode { shouldRedact = shouldRedact, isImportantForContentCapture = true, isVisible = isVisible, - visibleRect = boundsInWindow.toRect() + visibleRect = visibleRect ) } - painter != null -> { - parent?.setImportantForCaptureToAncestors(true) - return ImageViewHierarchyNode( - x = positionInWindow.x, - y = positionInWindow.y, - width = node.width, - height = node.height, - elevation = (parent?.elevation ?: 0f), - distance = distance, - parent = parent, - isVisible = isVisible, - isImportantForContentCapture = true, - shouldRedact = shouldRedact && painter.isRedactable(), - visibleRect = boundsInWindow.toRect() - ) + else -> { + val painter = node.findPainter() + if (painter != null) { + val shouldRedact = isVisible && node.shouldRedact(isImage = true, options) + + parent?.setImportantForCaptureToAncestors(true) + ImageViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + isVisible = isVisible, + isImportantForContentCapture = true, + shouldRedact = shouldRedact && painter.isRedactable(), + visibleRect = visibleRect + ) + } else { + val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + + GenericViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldRedact = shouldRedact, // TODO: use custom modifier to mark views that should be redacted/ignored + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = visibleRect + ) + } } } - - return GenericViewHierarchyNode( - x = positionInWindow.x, - y = positionInWindow.y, - width = node.width, - height = node.height, - elevation = (parent?.elevation ?: 0f), - distance = distance, - parent = parent, - shouldRedact = shouldRedact, // TODO: use custom modifier to mark views that should be redacted/ignored - isImportantForContentCapture = false, /* will be set by children */ - isVisible = isVisible, - visibleRect = boundsInWindow.toRect() - ) } fun fromView(view: View, parent: ViewHierarchyNode?, options: SentryOptions): Boolean { @@ -183,32 +151,37 @@ internal object ComposeViewHierarchyNode { return false } - val semanticsNodes = (view as? RootForTest)?.semanticsOwner?.getAllSemanticsNodesToMap(true) ?: return false - val rootNode = (view as? Owner)?.root ?: return false - rootNode.traverse(parent, semanticsNodes, options) + try { + val rootNode = (view as? Owner)?.root ?: return false + rootNode.traverse(parent, options) + } catch (e: Throwable) { + options.logger.log(SentryLevel.ERROR, e, """ + Error traversing Compose view. Most likely you're using an unsupported version of + androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer + version, please open a github issue with the version you're using, so we can add + support for it. + """.trimIndent()) + return false + } + return true } - /** - * Backport of https://github.com/androidx/androidx/blob/d0b13cd790006c94a2665474a91e465af4beb094/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt#L81-L100 - * which got changed in newer versions - */ - private fun SemanticsOwner.getAllSemanticsNodesToMap( - useUnmergedTree: Boolean = false, - ): Map { - val nodes = mutableMapOf() - - fun findAllSemanticNodesRecursive(currentNode: SemanticsNode) { - nodes[currentNode.id] = currentNode - val children = currentNode.children - for (index in children.indices) { - val node = children[index] - findAllSemanticNodesRecursive(node) - } + private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, options: SentryOptions) { + val children = this.children + if (children.isEmpty()) { + return } - val root = if (useUnmergedTree) unmergedRootSemanticsNode else rootSemanticsNode - findAllSemanticNodesRecursive(root) - return nodes + val childNodes = ArrayList(children.size) + for (index in children.indices) { + val child = children[index] + val childNode = fromComposeNode(child, parentNode, index, options) + if (childNode != null) { + childNodes.add(childNode) + child.traverse(childNode, options) + } + } + parentNode.children = childNodes } } From 31722cebe89e85ed05ea0844bacebc1557baabce Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Sep 2024 17:29:06 +0200 Subject: [PATCH 3/9] Custom redaction works for Compose --- .../android/replay/ModifierExtensions.kt | 29 +++++++++++++++++++ .../android/replay/ScreenshotRecorder.kt | 13 +++------ .../io/sentry/android/replay/util/Nodes.kt | 16 ++++++++++ .../viewhierarchy/ComposeViewHierarchyNode.kt | 17 +++++++++-- .../android/compose/ComposeActivity.kt | 3 +- 5 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt new file mode 100644 index 0000000000..b1b119a89c --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt @@ -0,0 +1,29 @@ +package io.sentry.android.replay + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.semantics +import io.sentry.android.replay.SentryReplayModifiers.SentryPrivacy + +public object SentryReplayModifiers { + val SentryPrivacy = SemanticsPropertyKey( + name = "SentryPrivacy", + mergePolicy = { parentValue, _ -> parentValue } + ) +} + +public fun Modifier.sentryReplayRedact(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "redact" + } + ) +} + +public fun Modifier.sentryReplayIgnore(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "ignore" + } + ) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index ea658f3e00..63f8b5d753 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -135,9 +135,9 @@ internal class ScreenshotRecorder( node.visibleRect ?: return@traverse false // TODO: investigate why it returns true on RN when it shouldn't - if (viewHierarchy.isObscured(node)) { - return@traverse true - } +// if (viewHierarchy.isObscured(node)) { +// return@traverse true +// } val (visibleRects, color) = when (node) { is ImageViewHierarchyNode -> { @@ -261,12 +261,7 @@ internal class ScreenshotRecorder( return } - var isCompose: Boolean - val time = measureNanoTime { - isCompose = ComposeViewHierarchyNode.fromView(this, parentNode, options) - } - if (isCompose) { - Log.e("TIME", String.format("%.2f", time / 1_000_000.0) + "ms") + if (ComposeViewHierarchyNode.fromView(this, parentNode, options)) { // if it's a compose view, we can skip the children as they are already traversed in // the ComposeViewHierarchyNode.fromView method return diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index c3461d936a..fd82d74a1a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -31,6 +31,8 @@ internal class ComposeTextLayout(internal val layout: TextLayoutResult, private 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 + /** * This method is necessary to redact images in Compose. * @@ -84,6 +86,20 @@ internal fun androidx.compose.ui.geometry.Rect.toRect(): Rect { internal data class TextAttributes(val color: Color?, val hasFillModifier: Boolean) +/** + * This method is necessary to redact 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 redact 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 { val modifierInfos = getModifierInfo() var color: Color? = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index 6bfdd0df5e..75b9121f2c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.TextLayoutResult import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryReplayOptions +import io.sentry.android.replay.SentryReplayModifiers import io.sentry.android.replay.util.ComposeTextLayout import io.sentry.android.replay.util.findPainter import io.sentry.android.replay.util.findTextAttributes @@ -43,6 +44,15 @@ internal object ComposeViewHierarchyNode { } private fun LayoutNode.shouldRedact(isImage: Boolean, options: SentryOptions): Boolean { + val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy) + if (sentryPrivacyModifier == "ignore") { + return false + } + + if (sentryPrivacyModifier == "redact") { + return true + } + val className = getProxyClassName(isImage) if (options.experimental.sessionReplay.ignoreViewClasses.contains(className)) { return false @@ -124,6 +134,9 @@ internal object ComposeViewHierarchyNode { } else { val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + // TODO: this currently does not support embedded AndroidViews, we'd have to + // TODO: traverse the ViewHierarchyNode here again. For now we can recommend + // TODO: using custom modifiers to obscure the entire node if it's sensitive GenericViewHierarchyNode( x = positionInWindow.x, y = positionInWindow.y, @@ -132,7 +145,7 @@ internal object ComposeViewHierarchyNode { elevation = (parent?.elevation ?: 0f), distance = distance, parent = parent, - shouldRedact = shouldRedact, // TODO: use custom modifier to mark views that should be redacted/ignored + shouldRedact = shouldRedact, isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect @@ -156,7 +169,7 @@ internal object ComposeViewHierarchyNode { rootNode.traverse(parent, options) } catch (e: Throwable) { options.logger.log(SentryLevel.ERROR, e, """ - Error traversing Compose view. Most likely you're using an unsupported version of + Error traversing Compose tree. Most likely you're using an unsupported version of androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer version, please open a github issue with the version you're using, so we can add support for it. diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 62755bcd81..52575cdc86 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -39,6 +39,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import coil.compose.AsyncImage +import io.sentry.android.replay.sentryReplayIgnore import io.sentry.compose.SentryTraced import io.sentry.compose.withSentryObservableEffect import io.sentry.samples.android.GithubAPI @@ -147,7 +148,7 @@ fun Github( .testTag("button_list_repos_async") .padding(top = 32.dp) ) { - Text("Make Request") + Text("Make Request", modifier = Modifier.sentryReplayIgnore()) } } } From 9a470c9e0bca844fddca021a1cd5ce7e1832d88b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Sep 2024 17:40:39 +0200 Subject: [PATCH 4/9] Formatting --- .../api/sentry-android-replay.api | 40 ++++++++++++++++-- .../android/replay/ScreenshotRecorder.kt | 2 - .../io/sentry/android/replay/util/Nodes.kt | 9 ++-- .../sentry/android/replay/util/TextLayout.kt | 1 + .../viewhierarchy/ComposeViewHierarchyNode.kt | 9 +++- .../replay/viewhierarchy/ViewHierarchyNode.kt | 41 +++++++++---------- .../android/compose/ComposeActivity.kt | 3 -- sentry/api/sentry.api | 9 ++++ sentry/src/main/java/io/sentry/ReplayApi.java | 16 ++------ 9 files changed, 84 insertions(+), 46 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 1c08379a49..f4f6897c91 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -26,6 +26,11 @@ public final class io/sentry/android/replay/GeneratedVideo { public fun toString ()Ljava/lang/String; } +public final class io/sentry/android/replay/ModifierExtensionsKt { + public static final fun sentryReplayIgnore (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public static final fun sentryReplayRedact (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; +} + public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { public abstract fun pause ()V public abstract fun resume ()V @@ -103,6 +108,11 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; } +public final class io/sentry/android/replay/SentryReplayModifiers { + public static final field INSTANCE Lio/sentry/android/replay/SentryReplayModifiers; + public final fun getSentryPrivacy ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; +} + public final class io/sentry/android/replay/SessionReplayOptionsKt { public static final fun getRedactAllImages (Lio/sentry/SentryReplayOptions;)Z public static final fun getRedactAllText (Lio/sentry/SentryReplayOptions;)Z @@ -130,6 +140,18 @@ public abstract interface class io/sentry/android/replay/gestures/TouchRecorderC public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V } +public final class io/sentry/android/replay/util/AndroidTextLayout : io/sentry/android/replay/util/TextLayout { + public fun (Landroid/text/Layout;)V + public fun getDominantTextColor ()Ljava/lang/Integer; + public fun getEllipsisCount (I)I + public fun getLineBottom (I)I + public fun getLineCount ()I + public fun getLineStart (I)I + public fun getLineTop (I)I + public fun getLineVisibleEnd (I)I + public fun getPrimaryHorizontal (II)F +} + public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Window$Callback { public final field delegate Landroid/view/Window$Callback; public fun (Landroid/view/Window$Callback;)V @@ -160,6 +182,17 @@ public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Wi public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; } +public abstract interface class io/sentry/android/replay/util/TextLayout { + public abstract fun getDominantTextColor ()Ljava/lang/Integer; + public abstract fun getEllipsisCount (I)I + public abstract fun getLineBottom (I)I + public abstract fun getLineCount ()I + public abstract fun getLineStart (I)I + public abstract fun getLineTop (I)I + public abstract fun getLineVisibleEnd (I)I + public abstract fun getPrimaryHorizontal (II)F +} + public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { public abstract fun getVideoTime ()J public abstract fun isStarted ()Z @@ -195,6 +228,7 @@ public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public final fun isObscured (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;)Z public final fun isVisible ()Z public final fun setChildren (Ljava/util/List;)V + public final fun setImportantForCaptureToAncestors (Z)V public final fun setImportantForContentCapture (Z)V public final fun traverse (Lkotlin/jvm/functions/Function1;)V } @@ -214,10 +248,10 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Imag } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$TextViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { - public fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V - public synthetic fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getDominantColor ()Ljava/lang/Integer; - public final fun getLayout ()Landroid/text/Layout; + public final fun getLayout ()Lio/sentry/android/replay/util/TextLayout; public final fun getPaddingLeft ()I public final fun getPaddingTop ()I } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 63f8b5d753..be06bd05f4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -13,7 +13,6 @@ import android.graphics.Rect import android.graphics.RectF import android.os.Build.VERSION import android.os.Build.VERSION_CODES -import android.util.Log import android.view.PixelCopy import android.view.View import android.view.ViewGroup @@ -39,7 +38,6 @@ import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt -import kotlin.system.measureNanoTime @TargetApi(26) internal class ScreenshotRecorder( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index fd82d74a1a..e011992cb5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -1,4 +1,5 @@ @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals and classes + package io.sentry.android.replay.util import android.graphics.Rect @@ -109,9 +110,11 @@ internal fun LayoutNode.findTextAttributes(): TextAttributes { val modifierClassName = modifier::class.java.name if (modifierClassName.contains("Text")) { color = try { - (modifier::class.java.getDeclaredField("color") - .apply { isAccessible = true } - .get(modifier) as? ColorProducer) + ( + modifier::class.java.getDeclaredField("color") + .apply { isAccessible = true } + .get(modifier) as? ColorProducer + ) ?.invoke() } catch (e: Throwable) { null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt index a26aa7cd52..cd07c6d170 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt @@ -5,6 +5,7 @@ package io.sentry.android.replay.util */ 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 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index 75b9121f2c..934454ad7f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -1,4 +1,5 @@ @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi @@ -168,12 +169,16 @@ internal object ComposeViewHierarchyNode { val rootNode = (view as? Owner)?.root ?: return false rootNode.traverse(parent, options) } catch (e: Throwable) { - options.logger.log(SentryLevel.ERROR, e, """ + options.logger.log( + SentryLevel.ERROR, + e, + """ Error traversing Compose tree. Most likely you're using an unsupported version of androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer version, please open a github issue with the version you're using, so we can add support for it. - """.trimIndent()) + """.trimIndent() + ) return false } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 7191df888b..a231e4f3d2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -2,7 +2,6 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi import android.graphics.Rect -import android.text.Layout import android.view.View import android.widget.ImageView import android.widget.TextView @@ -273,26 +272,26 @@ sealed class ViewHierarchyNode( val (isVisible, visibleRect) = view.isVisibleToUser() val shouldRedact = isVisible && view.shouldRedact(options) when (view) { - is TextView -> { - parent?.setImportantForCaptureToAncestors(true) - return TextViewHierarchyNode( - layout = view.layout?.let { AndroidTextLayout(it) }, - dominantColor = view.currentTextColor.toOpaque(), - paddingLeft = view.totalPaddingLeft, - paddingTop = view.totalPaddingTopSafe, - x = view.x, - y = view.y, - width = view.width, - height = view.height, - elevation = (parent?.elevation ?: 0f) + view.elevation, - shouldRedact = shouldRedact, - distance = distance, - parent = parent, - isImportantForContentCapture = true, - isVisible = isVisible, - visibleRect = visibleRect - ) - } + is TextView -> { + parent?.setImportantForCaptureToAncestors(true) + return TextViewHierarchyNode( + layout = view.layout?.let { AndroidTextLayout(it) }, + dominantColor = view.currentTextColor.toOpaque(), + paddingLeft = view.totalPaddingLeft, + paddingTop = view.totalPaddingTopSafe, + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + shouldRedact = shouldRedact, + distance = distance, + parent = parent, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect + ) + } is ImageView -> { parent?.setImportantForCaptureToAncestors(true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 52575cdc86..4e039fe047 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -9,9 +9,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -26,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Color.Companion import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 3cf11a434d..346ac872dd 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1671,6 +1671,14 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } +public final class io/sentry/ReplayApi { + public fun (Lio/sentry/ReplayController;)V + public fun getReplayId ()Lio/sentry/protocol/SentryId; + public fun isRecording ()Z + public fun pause ()V + public fun resume ()V +} + public abstract interface class io/sentry/ReplayBreadcrumbConverter { public abstract fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } @@ -1893,6 +1901,7 @@ public final class io/sentry/Sentry { public static fun pushScope ()V public static fun removeExtra (Ljava/lang/String;)V public static fun removeTag (Ljava/lang/String;)V + public static fun replay ()Lio/sentry/ReplayApi; public static fun reportFullDisplayed ()V public static fun reportFullyDisplayed ()V public static fun setCurrentHub (Lio/sentry/IHub;)V diff --git a/sentry/src/main/java/io/sentry/ReplayApi.java b/sentry/src/main/java/io/sentry/ReplayApi.java index 37b46662f6..d006258b88 100644 --- a/sentry/src/main/java/io/sentry/ReplayApi.java +++ b/sentry/src/main/java/io/sentry/ReplayApi.java @@ -10,30 +10,22 @@ public ReplayApi(final @NotNull ReplayController replayController) { this.replayController = replayController; } - /** - * Resumes screen recording if it was paused. - */ + /** Resumes screen recording if it was paused. */ public void resume() { replayController.resume(); } - /** - * Pauses screen recording entirely, but does not stop the current replay. - */ + /** Pauses screen recording entirely, but does not stop the current replay. */ public void pause() { replayController.pause(); } - /** - * Returns whether the replay is currently running - */ + /** Returns whether the replay is currently running */ public boolean isRecording() { return replayController.isRecording(); } - /** - * The id of the currently running replay or {@link SentryId#EMPTY_ID} if no replay is running - */ + /** The id of the currently running replay or {@link SentryId#EMPTY_ID} if no replay is running */ @NotNull public SentryId getReplayId() { return replayController.getReplayId(); From 337677d346bf6958125d2edb7da240f3293d0c2e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Sep 2024 17:57:12 +0200 Subject: [PATCH 5/9] Clean up --- buildSrc/src/main/java/Config.kt | 4 +++ sentry-android-replay/build.gradle.kts | 2 +- .../sentry-samples-android/build.gradle.kts | 2 +- .../android/compose/ComposeActivity.kt | 2 +- sentry/api/sentry.api | 9 ----- sentry/src/main/java/io/sentry/ReplayApi.java | 33 ------------------- sentry/src/main/java/io/sentry/Sentry.java | 7 ---- 7 files changed, 7 insertions(+), 52 deletions(-) delete mode 100644 sentry/src/main/java/io/sentry/ReplayApi.java diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8777d926a9..8d9ee1a5a3 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -147,8 +147,12 @@ object Config { val composeActivity = "androidx.activity:activity-compose:1.4.0" val composeFoundation = "androidx.compose.foundation:foundation:$composeVersion" val composeUi = "androidx.compose.ui:ui:$composeVersion" + + // Note: don't change without testing forwards compatibility + val composeUiReplay = "androidx.compose.ui:ui:1.5.0" val composeFoundationLayout = "androidx.compose.foundation:foundation-layout:$composeVersion" val composeMaterial = "androidx.compose.material3:material3:1.0.0-alpha13" + val composeCoil = "io.coil-kt:coil-compose:2.6.0" val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.8.2" diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index fb03d169f4..20969329e6 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -68,7 +68,7 @@ kotlin { dependencies { api(projects.sentry) - compileOnly("androidx.compose.ui:ui:1.5.0") + compileOnly(Config.Libs.composeUiReplay) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) // tests diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 8e5f918241..204ef83fc2 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -127,12 +127,12 @@ dependencies { implementation(Config.Libs.retrofit2) implementation(Config.Libs.retrofit2Gson) - implementation("io.coil-kt:coil-compose:2.6.0") implementation(Config.Libs.composeActivity) implementation(Config.Libs.composeFoundation) implementation(Config.Libs.composeFoundationLayout) implementation(Config.Libs.composeNavigation) implementation(Config.Libs.composeMaterial) + implementation(Config.Libs.composeCoil) debugImplementation(Config.Libs.leakCanary) } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 4e039fe047..23ecd893c3 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -124,7 +124,7 @@ fun Github( ) AsyncImage( model = "https://i.imgur.com/tie6A3J.jpeg", - contentDescription = "IMG", + contentDescription = null, modifier = Modifier.padding(vertical = 16.dp) ) TextField( diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 346ac872dd..3cf11a434d 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1671,14 +1671,6 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } -public final class io/sentry/ReplayApi { - public fun (Lio/sentry/ReplayController;)V - public fun getReplayId ()Lio/sentry/protocol/SentryId; - public fun isRecording ()Z - public fun pause ()V - public fun resume ()V -} - public abstract interface class io/sentry/ReplayBreadcrumbConverter { public abstract fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } @@ -1901,7 +1893,6 @@ public final class io/sentry/Sentry { public static fun pushScope ()V public static fun removeExtra (Ljava/lang/String;)V public static fun removeTag (Ljava/lang/String;)V - public static fun replay ()Lio/sentry/ReplayApi; public static fun reportFullDisplayed ()V public static fun reportFullyDisplayed ()V public static fun setCurrentHub (Lio/sentry/IHub;)V diff --git a/sentry/src/main/java/io/sentry/ReplayApi.java b/sentry/src/main/java/io/sentry/ReplayApi.java deleted file mode 100644 index d006258b88..0000000000 --- a/sentry/src/main/java/io/sentry/ReplayApi.java +++ /dev/null @@ -1,33 +0,0 @@ -package io.sentry; - -import io.sentry.protocol.SentryId; -import org.jetbrains.annotations.NotNull; - -public final class ReplayApi { - private final @NotNull ReplayController replayController; - - public ReplayApi(final @NotNull ReplayController replayController) { - this.replayController = replayController; - } - - /** Resumes screen recording if it was paused. */ - public void resume() { - replayController.resume(); - } - - /** Pauses screen recording entirely, but does not stop the current replay. */ - public void pause() { - replayController.pause(); - } - - /** Returns whether the replay is currently running */ - public boolean isRecording() { - return replayController.isRecording(); - } - - /** The id of the currently running replay or {@link SentryId#EMPTY_ID} if no replay is running */ - @NotNull - public SentryId getReplayId() { - return replayController.getReplayId(); - } -} diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 18359e6150..08571e151a 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -985,13 +985,6 @@ public static MetricsApi metrics() { return getCurrentHub().metrics(); } - /** the replay API for the current hub */ - @NotNull - @ApiStatus.Experimental - public static ReplayApi replay() { - return new ReplayApi(getCurrentHub().getOptions().getReplayController()); - } - /** * Configuration options callback * From 3158682feeb472918d93c023c7f2fc617a377942 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Sep 2024 23:32:28 +0200 Subject: [PATCH 6/9] Test --- sentry-android-replay/build.gradle.kts | 7 ++ .../ComposeRedactionOptionsTest.kt | 84 +++++++++++++++++++ .../viewhierarchy/RedactionOptionsTest.kt | 4 +- 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 20969329e6..2367eeea8e 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -81,6 +81,13 @@ dependencies { testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.awaitility) + testImplementation("androidx.compose.ui:ui-test-junit4:1.5.0") + testImplementation(Config.Libs.composeActivity) + testImplementation(Config.Libs.composeUi) + testImplementation(Config.Libs.composeCoil) + testImplementation(Config.Libs.composeFoundation) + testImplementation(Config.Libs.composeFoundationLayout) + testImplementation(Config.Libs.composeMaterial) } tasks.withType { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt new file mode 100644 index 0000000000..c4de72fec2 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt @@ -0,0 +1,84 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil.compose.AsyncImage +import io.sentry.android.replay.sentryReplayIgnore +import kotlinx.coroutines.launch +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ComposeRedactionOptionsTest { + + val composeTestRule = ComposeTestRule() + + @Before + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + } +} + +private class ExampleActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + ) { + Image( + painter = painterResource(IR.drawable.logo_pocket_casts), + contentDescription = "LOGO", + colorFilter = ColorFilter.tint(Color.Black), + modifier = Modifier.padding(vertical = 16.dp) + ) + AsyncImage( + model = "https://i.imgur.com/tie6A3J.jpeg", + contentDescription = null, + modifier = Modifier.padding(vertical = 16.dp) + ) + TextField( + value = user, + onValueChange = { newText -> + user = newText + } + ) + Text("Random repo") + Button( + onClick = {}, + modifier = Modifier + .testTag("button_list_repos_async") + .padding(top = 32.dp) + ) { + Text("Make Request", modifier = Modifier.sentryReplayIgnore()) + } + } + } + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt index 8ffffd046d..e5b895194b 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt @@ -20,10 +20,10 @@ import io.sentry.android.replay.sentryReplayIgnore import io.sentry.android.replay.sentryReplayRedact import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode -import org.junit.Before import org.junit.runner.RunWith import org.robolectric.Robolectric.buildActivity import org.robolectric.annotation.Config +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -32,7 +32,7 @@ import kotlin.test.assertTrue @Config(sdk = [30]) class RedactionOptionsTest { - @Before + @BeforeTest fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") } From 64bbff0d275b0d9c316a645e593bc68a0be1dac6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 1 Oct 2024 22:47:07 +0200 Subject: [PATCH 7/9] Add tests --- buildSrc/src/main/java/Config.kt | 3 +- .../api/sentry-android-replay.api | 14 ++ sentry-android-replay/build.gradle.kts | 9 +- .../android/replay/ScreenshotRecorder.kt | 33 +-- .../io/sentry/android/replay/util/Views.kt | 36 ++++ .../src/test/AndroidManifest.xml | 24 +++ .../replay/util/TextViewDominantColorTest.kt | 6 +- .../ComposeRedactionOptionsTest.kt | 198 ++++++++++++++++-- .../viewhierarchy/RedactionOptionsTest.kt | 70 +++---- 9 files changed, 300 insertions(+), 93 deletions(-) create mode 100644 sentry-android-replay/src/test/AndroidManifest.xml diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8d9ee1a5a3..adba7a8cb5 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -148,8 +148,7 @@ object Config { val composeFoundation = "androidx.compose.foundation:foundation:$composeVersion" val composeUi = "androidx.compose.ui:ui:$composeVersion" - // Note: don't change without testing forwards compatibility - val composeUiReplay = "androidx.compose.ui:ui:1.5.0" + val composeUiReplay = "androidx.compose.ui:ui:1.5.0" // Note: don't change without testing forwards compatibility val composeFoundationLayout = "androidx.compose.foundation:foundation-layout:$composeVersion" val composeMaterial = "androidx.compose.material3:material3:1.0.0-alpha13" val composeCoil = "io.coil-kt:coil-compose:2.6.0" diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index f4f6897c91..4b4c59b9a2 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -7,11 +7,13 @@ public final class io/sentry/android/replay/BuildConfig { } public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { + public static final field $stable I public fun ()V public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } public final class io/sentry/android/replay/GeneratedVideo { + public static final field $stable I public fun (Ljava/io/File;IJ)V public final fun component1 ()Ljava/io/File; public final fun component2 ()I @@ -39,6 +41,7 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos } public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public final fun addFrame (Ljava/io/File;JLjava/lang/String;)V @@ -55,6 +58,7 @@ public final class io/sentry/android/replay/ReplayCache$Companion { } public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, java/io/Closeable { + public static final field $stable I public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -83,6 +87,7 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb } public final class io/sentry/android/replay/ScreenshotRecorderConfig { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; public fun (IIFFII)V public final fun component1 ()I @@ -109,6 +114,7 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { } public final class io/sentry/android/replay/SentryReplayModifiers { + public static final field $stable I public static final field INSTANCE Lio/sentry/android/replay/SentryReplayModifiers; public final fun getSentryPrivacy ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; } @@ -126,12 +132,14 @@ public final class io/sentry/android/replay/ViewExtensionsKt { } public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { + public static final field $stable I public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V public fun onRootViewsChanged (Landroid/view/View;Z)V public final fun stop ()V } public final class io/sentry/android/replay/gestures/ReplayGestureConverter { + public static final field $stable I public fun (Lio/sentry/transport/ICurrentDateProvider;)V public final fun convert (Landroid/view/MotionEvent;Lio/sentry/android/replay/ScreenshotRecorderConfig;)Ljava/util/List; } @@ -141,6 +149,7 @@ public abstract interface class io/sentry/android/replay/gestures/TouchRecorderC } public final class io/sentry/android/replay/util/AndroidTextLayout : io/sentry/android/replay/util/TextLayout { + public static final field $stable I public fun (Landroid/text/Layout;)V public fun getDominantTextColor ()Ljava/lang/Integer; public fun getEllipsisCount (I)I @@ -202,6 +211,7 @@ public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer } public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentry/android/replay/video/SimpleFrameMuxer { + public static final field $stable I public fun (Ljava/lang/String;F)V public fun getVideoTime ()J public fun isStarted ()Z @@ -211,6 +221,7 @@ public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentr } public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -238,16 +249,19 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Comp } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$GenericViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$ImageViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$TextViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V public synthetic fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getDominantColor ()Ljava/lang/Integer; diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 2367eeea8e..ad7679bae4 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -26,6 +26,14 @@ android { buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Config.androidComposeCompilerVersion + } + buildTypes { getByName("debug") getByName("release") { @@ -81,7 +89,6 @@ dependencies { testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.awaitility) - testImplementation("androidx.compose.ui:ui-test-junit4:1.5.0") testImplementation(Config.Libs.composeActivity) testImplementation(Config.Libs.composeUi) testImplementation(Config.Libs.composeCoil) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index be06bd05f4..5b779babe0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -15,7 +15,6 @@ import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.view.PixelCopy import android.view.View -import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowManager import io.sentry.SentryLevel.DEBUG @@ -27,7 +26,7 @@ import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.getVisibleRects import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely -import io.sentry.android.replay.viewhierarchy.ComposeViewHierarchyNode +import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -123,7 +122,7 @@ internal class ScreenshotRecorder( } val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) - root.traverse(viewHierarchy) + root.traverse(viewHierarchy, options) recorder.submitSafely(options, "screenshot_recorder.redact") { val canvas = Canvas(bitmap) @@ -254,34 +253,6 @@ internal class ScreenshotRecorder( return singlePixelBitmap.getPixel(0, 0) } - private fun View.traverse(parentNode: ViewHierarchyNode) { - if (this !is ViewGroup) { - return - } - - if (ComposeViewHierarchyNode.fromView(this, parentNode, options)) { - // if it's a compose view, we can skip the children as they are already traversed in - // the ComposeViewHierarchyNode.fromView method - return - } - - if (this.childCount == 0) { - return - } - - val childNodes = ArrayList(this.childCount) - for (i in 0 until childCount) { - val child = getChildAt(i) - if (child != null) { - val childNode = - ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) - childNodes.add(childNode) - child.traverse(childNode) - } - } - parentNode.children = childNodes - } - private class RecorderExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index f6a9b2f322..1c6111c1b0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -16,9 +16,45 @@ import android.text.Layout import android.text.Spanned import android.text.style.ForegroundColorSpan import android.view.View +import android.view.ViewGroup import android.widget.TextView +import io.sentry.SentryOptions +import io.sentry.android.replay.viewhierarchy.ComposeViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.NullPointerException +/** + * Recursively traverses the view hierarchy and creates a [ViewHierarchyNode] for each view. + * Supports Compose view hierarchy as well. + */ +internal fun View.traverse(parentNode: ViewHierarchyNode, options: SentryOptions) { + if (this !is ViewGroup) { + return + } + + if (ComposeViewHierarchyNode.fromView(this, parentNode, options)) { + // if it's a compose view, we can skip the children as they are already traversed in + // the ComposeViewHierarchyNode.fromView method + return + } + + if (this.childCount == 0) { + return + } + + val childNodes = ArrayList(this.childCount) + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child != null) { + val childNode = + ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) + childNodes.add(childNode) + child.traverse(childNode, options) + } + } + parentNode.children = childNodes +} + /** * Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718 */ diff --git a/sentry-android-replay/src/test/AndroidManifest.xml b/sentry-android-replay/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..c8f45a53bb --- /dev/null +++ b/sentry-android-replay/src/test/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt index ec545ed109..9a5b805ad7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -36,7 +36,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertNull(node.layout.dominantTextColor) + assertNull(node.layout?.dominantTextColor) } @Test @@ -55,7 +55,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertEquals(Color.RED, node.layout.dominantTextColor) + assertEquals(Color.RED, node.layout?.dominantTextColor) } @Test @@ -75,7 +75,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertEquals(Color.BLACK, node.layout.dominantTextColor) + assertEquals(Color.BLACK, node.layout?.dominantTextColor) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt index c4de72fec2..981e351408 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt @@ -1,10 +1,10 @@ package io.sentry.android.replay.viewhierarchy import android.app.Activity +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -14,36 +14,199 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import coil.compose.AsyncImage +import io.sentry.SentryOptions +import io.sentry.android.replay.redactAllImages +import io.sentry.android.replay.redactAllText import io.sentry.android.replay.sentryReplayIgnore -import kotlinx.coroutines.launch +import io.sentry.android.replay.sentryReplayRedact +import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.traverse +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import org.junit.Before import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) class ComposeRedactionOptionsTest { - val composeTestRule = ComposeTestRule() - @Before fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") + ComposeRedactionOptionsActivity.textModifierApplier = null + ComposeRedactionOptionsActivity.containerModifierApplier = null + } + + @Test + fun `when redactAllText is set all Text nodes are redacted`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.all { it.shouldRedact }) + // just a sanity check for parsing the tree + assertEquals("Random repo", (textNodes[1].layout as ComposeTextLayout).layout.layoutInput.text.text) + } + + @Test + fun `when redactAllText is set to false all Text nodes are ignored`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.none { it.shouldRedact }) + } + + @Test + fun `when redactAllImages is set all Image nodes are redacted`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllImages = true + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.all { it.shouldRedact }) + } + + @Test + fun `when redactAllImages is set to false all Image nodes are ignored`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllImages = false + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.none { it.shouldRedact }) + } + + @Test + fun `when sentry-redact modifier is set redacts the node`() { + ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayRedact() } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertTrue(it.shouldRedact) + } else { + assertFalse(it.shouldRedact) + } + } + } + + @Test + fun `when sentry-ignore modifier is set ignores the node`() { + ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayIgnore() } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldRedact) + } else { + assertTrue(it.shouldRedact) + } + } + } + + @Test + fun `when view is not visible, does not redact the view`() { + ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.semantics { invisibleToUser() } } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldRedact) + } else { + assertTrue(it.shouldRedact) + } + } + } + + @Test + fun `when a container view is ignored its children are not ignored`() { + ComposeRedactionOptionsActivity.containerModifierApplier = { Modifier.sentryReplayIgnore() } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions() + + val allNodes = activity.get().collectNodesOfType(options) + val imageNodes = allNodes.filterIsInstance() + val textNodes = allNodes.filterIsInstance() + val genericNodes = allNodes.filterIsInstance() + assertTrue(imageNodes.all { it.shouldRedact }) + assertTrue(textNodes.all { it.shouldRedact }) + assertTrue(genericNodes.none { it.shouldRedact }) + } + + private inline fun Activity.collectNodesOfType(options: SentryOptions): List { + val root = window.decorView + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) + root.traverse(viewHierarchy, options) + + val nodes = mutableListOf() + viewHierarchy.traverse { + if (it is T) { + nodes += it + } + return@traverse true + } + return nodes } } -private class ExampleActivity : ComponentActivity() { +private class ComposeRedactionOptionsActivity : ComponentActivity() { + + companion object { + var textModifierApplier: (() -> Modifier)? = null + var containerModifierApplier: (() -> Modifier)? = null + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! setContent { Column( @@ -51,23 +214,16 @@ private class ExampleActivity : ComponentActivity() { horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() + .then(containerModifierApplier?.invoke() ?: Modifier) ) { - Image( - painter = painterResource(IR.drawable.logo_pocket_casts), - contentDescription = "LOGO", - colorFilter = ColorFilter.tint(Color.Black), - modifier = Modifier.padding(vertical = 16.dp) - ) AsyncImage( - model = "https://i.imgur.com/tie6A3J.jpeg", + model = Uri.fromFile(File(image.toURI())), contentDescription = null, modifier = Modifier.padding(vertical = 16.dp) ) TextField( - value = user, - onValueChange = { newText -> - user = newText - } + value = TextFieldValue("Placeholder"), + onValueChange = { _ -> } ) Text("Random repo") Button( @@ -76,7 +232,7 @@ private class ExampleActivity : ComponentActivity() { .testTag("button_list_repos_async") .padding(top = 32.dp) ) { - Text("Make Request", modifier = Modifier.sentryReplayIgnore()) + Text("Make Request", modifier = Modifier.then(textModifierApplier?.invoke() ?: Modifier)) } } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt index e5b895194b..c1a50f7a62 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt @@ -39,14 +39,14 @@ class RedactionOptionsTest { @Test fun `when redactAllText is set all TextView nodes are redacted`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) assertTrue(textNode is TextViewHierarchyNode) assertTrue(textNode.shouldRedact) @@ -57,14 +57,14 @@ class RedactionOptionsTest { @Test fun `when redactAllText is set to false all TextView nodes are ignored`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = false } - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) assertTrue(textNode is TextViewHierarchyNode) assertFalse(textNode.shouldRedact) @@ -75,13 +75,13 @@ class RedactionOptionsTest { @Test fun `when redactAllImages is set all ImageView nodes are redacted`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllImages = true } - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) assertTrue(imageNode is ImageViewHierarchyNode) assertTrue(imageNode.shouldRedact) @@ -89,13 +89,13 @@ class RedactionOptionsTest { @Test fun `when redactAllImages is set to false all ImageView nodes are ignored`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllImages = false } - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) assertTrue(imageNode is ImageViewHierarchyNode) assertFalse(imageNode.shouldRedact) @@ -103,98 +103,98 @@ class RedactionOptionsTest { @Test fun `when sentry-redact tag is set redacts the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = false } - ExampleActivity.textView!!.tag = "sentry-redact" - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.tag = "sentry-redact" + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertTrue(textNode.shouldRedact) } @Test fun `when sentry-ignore tag is set ignores the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - ExampleActivity.textView!!.tag = "sentry-ignore" - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.tag = "sentry-ignore" + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertFalse(textNode.shouldRedact) } @Test fun `when sentry-privacy tag is set to redact redacts the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = false } - ExampleActivity.textView!!.sentryReplayRedact() - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.sentryReplayRedact() + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertTrue(textNode.shouldRedact) } @Test fun `when sentry-privacy tag is set to ignore ignores the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - ExampleActivity.textView!!.sentryReplayIgnore() - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.sentryReplayIgnore() + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertFalse(textNode.shouldRedact) } @Test fun `when view is not visible, does not redact the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - ExampleActivity.textView!!.visibility = View.GONE - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.visibility = View.GONE + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertFalse(textNode.shouldRedact) } @Test fun `when added to redact list redacts custom view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactViewClasses.add(CustomView::class.java.canonicalName) } - val customViewNode = ViewHierarchyNode.fromView(ExampleActivity.customView!!, null, 0, options) + val customViewNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.customView!!, null, 0, options) assertTrue(customViewNode.shouldRedact) } @Test fun `when subclass is added to ignored classes ignores all instances of that class`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true // all TextView subclasses experimental.sessionReplay.ignoreViewClasses.add(RadioButton::class.java.canonicalName) } - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) assertTrue(textNode.shouldRedact) assertFalse(radioButtonNode.shouldRedact) @@ -202,15 +202,15 @@ class RedactionOptionsTest { @Test fun `when a container view is ignored its children are not ignored`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.ignoreViewClasses.add(LinearLayout::class.java.canonicalName) } - val linearLayoutNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!.parent as LinearLayout, null, 0, options) - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + val linearLayoutNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) assertFalse(linearLayoutNode.shouldRedact) assertTrue(textNode.shouldRedact) @@ -226,7 +226,7 @@ private class CustomView(context: Context) : View(context) { } } -private class ExampleActivity : Activity() { +private class RedactionOptionsActivity : Activity() { companion object { var textView: TextView? = null From 7069634a3b62eacbb20fe126f9868fae3162c8ba Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 1 Oct 2024 23:38:00 +0200 Subject: [PATCH 8/9] Changelog --- CHANGELOG.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f43332c4..fbd66ebab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,20 @@ - Add support for `feedback` envelope header item type ([#3687](https://github.com/getsentry/sentry-java/pull/3687)) - Add breadcrumb.origin field ([#3727](https://github.com/getsentry/sentry-java/pull/3727)) +- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) + - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags + - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code + - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions + - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well + - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` + - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified +- Session Replay: Support Jetpack Compose masking ([#3739](https://github.com/getsentry/sentry-java/pull/3739)) + - To selectively mask/unmask @Composables, use `Modifier.sentryReplayRedact()` and `Modifier.sentryReplayIgnore()` modifiers ### Fixes - Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) - Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) -- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) - - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags - - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code - - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions - - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well - - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` - - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified - Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) *Breaking changes*: From f9420e060d801b49b29a33e1a6dafda892b26fd2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 8 Oct 2024 20:41:58 +0200 Subject: [PATCH 9/9] Replace logo with sentry --- .../android/compose/ComposeActivity.kt | 2 +- .../main/res/drawable/logo_pocket_casts.xml | 50 ------------------- .../src/main/res/drawable/sentry_glyph.xml | 9 ++++ 3 files changed, 10 insertions(+), 51 deletions(-) delete mode 100644 sentry-samples/sentry-samples-android/src/main/res/drawable/logo_pocket_casts.xml create mode 100644 sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 23ecd893c3..03d9e8d049 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -117,7 +117,7 @@ fun Github( .fillMaxSize() ) { Image( - painter = painterResource(IR.drawable.logo_pocket_casts), + painter = painterResource(IR.drawable.sentry_glyph), contentDescription = "LOGO", colorFilter = ColorFilter.tint(Color.Black), modifier = Modifier.padding(vertical = 16.dp) diff --git a/sentry-samples/sentry-samples-android/src/main/res/drawable/logo_pocket_casts.xml b/sentry-samples/sentry-samples-android/src/main/res/drawable/logo_pocket_casts.xml deleted file mode 100644 index 1003ee7d0f..0000000000 --- a/sentry-samples/sentry-samples-android/src/main/res/drawable/logo_pocket_casts.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - diff --git a/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml new file mode 100644 index 0000000000..28a3442987 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml @@ -0,0 +1,9 @@ + + +