From d1b1ee9025d9b98aa92763220650c426f0bc3b97 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 25 Sep 2024 23:07:38 +0200 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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 08/19] 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 8d95e5ce71859d8fd1df63e97e8cc1ba3393b889 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 2 Oct 2024 11:49:19 +0200 Subject: [PATCH 09/19] Change terminology from redact/ignore to mask/unmask --- CHANGELOG.md | 14 +- .../android/core/ManifestMetadataReader.java | 8 +- .../core/ManifestMetadataReaderTest.kt | 14 +- .../api/sentry-android-replay.api | 18 +- sentry-android-replay/proguard-rules.pro | 4 +- .../android/replay/ModifierExtensions.kt | 8 +- .../android/replay/ScreenshotRecorder.kt | 4 +- .../android/replay/SessionReplayOptions.kt | 18 +- .../sentry/android/replay/ViewExtensions.kt | 12 +- .../io/sentry/android/replay/util/Nodes.kt | 10 +- .../io/sentry/android/replay/util/Views.kt | 4 +- .../viewhierarchy/ComposeViewHierarchyNode.kt | 24 +- .../replay/viewhierarchy/ViewHierarchyNode.kt | 42 +-- ...nsTest.kt => ComposeMaskingOptionsTest.kt} | 96 +++--- .../viewhierarchy/MaskingOptionsTest.kt | 278 ++++++++++++++++++ .../viewhierarchy/RedactionOptionsTest.kt | 278 ------------------ .../src/main/AndroidManifest.xml | 2 +- .../android/compose/ComposeActivity.kt | 4 +- sentry/api/sentry.api | 12 +- .../java/io/sentry/SentryReplayOptions.java | 64 ++-- 20 files changed, 457 insertions(+), 457 deletions(-) rename sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/{ComposeRedactionOptionsTest.kt => ComposeMaskingOptionsTest.kt} (67%) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt delete mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd66ebab2..9c9124e309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,15 @@ - 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")` +- Session Replay: Add options to selectively mask/unmask views captured in replay. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) + - `android:tag="sentry-mask|sentry-unmask"` in XML or `view.setTag("sentry-mask|sentry-unmask")` 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, "mask|unmask")` in code + - `view.sentryReplayMask()` or `view.sentryReplayUnmask()` extension functions + - mask/unmask `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addMaskViewClass()` or `options.experimental.sessionReplay.addUnmaskViewClass()`. Note, that all of the view subclasses/subtypes will be masked/unmasked as well + - For example, (this is already a default behavior) to mask all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addMaskViewClass("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 + - To selectively mask/unmask @Composables, use `Modifier.sentryReplayMask()` and `Modifier.sentryReplayUnmask()` modifiers ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index fc66c9d6ee..96d54d98de 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -108,9 +108,9 @@ final class ManifestMetadataReader { static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.on-error-sample-rate"; - static final String REPLAYS_REDACT_ALL_TEXT = "io.sentry.session-replay.redact-all-text"; + static final String REPLAYS_MASK_ALL_TEXT = "io.sentry.session-replay.mask-all-text"; - static final String REPLAYS_REDACT_ALL_IMAGES = "io.sentry.session-replay.redact-all-images"; + static final String REPLAYS_MASK_ALL_IMAGES = "io.sentry.session-replay.mask-all-images"; /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -409,12 +409,12 @@ static void applyMetadata( options .getExperimental() .getSessionReplay() - .setRedactAllText(readBool(metadata, logger, REPLAYS_REDACT_ALL_TEXT, true)); + .setMaskAllText(readBool(metadata, logger, REPLAYS_MASK_ALL_TEXT, true)); options .getExperimental() .getSessionReplay() - .setRedactAllImages(readBool(metadata, logger, REPLAYS_REDACT_ALL_IMAGES, true)); + .setMaskAllImages(readBool(metadata, logger, REPLAYS_MASK_ALL_IMAGES, true)); } options diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 8a86fcb2c5..e068af7b1c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1465,21 +1465,21 @@ class ManifestMetadataReaderTest { } @Test - fun `applyMetadata reads session replay redact flags to options`() { + fun `applyMetadata reads session replay mask flags to options`() { // Arrange - val bundle = bundleOf(ManifestMetadataReader.REPLAYS_REDACT_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_REDACT_ALL_IMAGES to false) + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_MASK_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_MASK_ALL_IMAGES to false) val context = fixture.getContext(metaData = bundle) // Act ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test - fun `applyMetadata reads session replay redact flags to options and keeps default if not found`() { + fun `applyMetadata reads session replay mask flags to options and keeps default if not found`() { // Arrange val context = fixture.getContext() @@ -1487,7 +1487,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 4b4c59b9a2..a08fb1dd98 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -29,8 +29,8 @@ public final class io/sentry/android/replay/GeneratedVideo { } 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 static final fun sentryReplayMask (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public static final fun sentryReplayUnmask (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; } public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { @@ -120,15 +120,15 @@ public final class io/sentry/android/replay/SentryReplayModifiers { } 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 - public static final fun setRedactAllImages (Lio/sentry/SentryReplayOptions;Z)V - public static final fun setRedactAllText (Lio/sentry/SentryReplayOptions;Z)V + public static final fun getMaskAllImages (Lio/sentry/SentryReplayOptions;)Z + public static final fun getMaskAllText (Lio/sentry/SentryReplayOptions;)Z + public static final fun setMaskAllImages (Lio/sentry/SentryReplayOptions;Z)V + public static final fun setMaskAllText (Lio/sentry/SentryReplayOptions;Z)V } public final class io/sentry/android/replay/ViewExtensionsKt { - public static final fun sentryReplayIgnore (Landroid/view/View;)V - public static final fun sentryReplayRedact (Landroid/view/View;)V + public static final fun sentryReplayMask (Landroid/view/View;)V + public static final fun sentryReplayUnmask (Landroid/view/View;)V } public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { @@ -230,7 +230,7 @@ public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public final fun getElevation ()F public final fun getHeight ()I public final fun getParent ()Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; - public final fun getShouldRedact ()Z + public final fun getShouldMask ()Z public final fun getVisibleRect ()Landroid/graphics/Rect; public final fun getWidth ()I public final fun getX ()F diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index 445c89b526..c537ff7399 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -2,13 +2,13 @@ # debugging stack traces. -keepattributes SourceFile,LineNumberTable -# Rules to detect Images/Icons and redact them +# Rules to detect Images/Icons and mask them -dontwarn androidx.compose.ui.graphics.painter.Painter -keepnames class * extends androidx.compose.ui.graphics.painter.Painter -keepclasseswithmembernames class * { androidx.compose.ui.graphics.painter.Painter painter; } -# Rules to detect Text colors and if they have Modifier.fillMaxWidth to later redact them +# Rules to detect Text colors and if they have Modifier.fillMaxWidth to later mask them -dontwarn androidx.compose.ui.graphics.ColorProducer -dontwarn androidx.compose.foundation.layout.FillElement -keepnames class androidx.compose.foundation.layout.FillElement 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 index b1b119a89c..b5d5222388 100644 --- 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 @@ -12,18 +12,18 @@ public object SentryReplayModifiers { ) } -public fun Modifier.sentryReplayRedact(): Modifier { +public fun Modifier.sentryReplayMask(): Modifier { return semantics( properties = { - this[SentryPrivacy] = "redact" + this[SentryPrivacy] = "mask" } ) } -public fun Modifier.sentryReplayIgnore(): Modifier { +public fun Modifier.sentryReplayUnmask(): Modifier { return semantics( properties = { - this[SentryPrivacy] = "ignore" + this[SentryPrivacy] = "unmask" } ) } 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 5b779babe0..8f823fa17c 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 @@ -124,11 +124,11 @@ internal class ScreenshotRecorder( val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) root.traverse(viewHierarchy, options) - recorder.submitSafely(options, "screenshot_recorder.redact") { + recorder.submitSafely(options, "screenshot_recorder.mask") { val canvas = Canvas(bitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { node -> - if (node.shouldRedact && (node.width > 0 && node.height > 0)) { + if (node.shouldMask && (node.width > 0 && node.height > 0)) { node.visibleRect ?: return@traverse false // TODO: investigate why it returns true on RN when it shouldn't diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt index e3e6605a96..fb5105565b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -2,30 +2,30 @@ package io.sentry.android.replay import io.sentry.SentryReplayOptions -// since we don't have getters for redactAllText and redactAllImages, they won't be accessible as +// since we don't have getters for maskAllText and maskAllimages, they won't be accessible as // properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter // delegates to the corresponding method in SentryReplayOptions /** - * Redact all text content. Draws a rectangle of text bounds with text color on top. By default - * only views extending TextView are redacted. + * Mask all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are masked. * *

Default is enabled. */ -var SentryReplayOptions.redactAllText: Boolean +var SentryReplayOptions.maskAllText: Boolean @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) get() = error("Getter not supported") - set(value) = setRedactAllText(value) + set(value) = setMaskAllText(value) /** - * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * Mask all image content. Draws a rectangle of image bounds with image's dominant color on top. * By default only views extending ImageView with BitmapDrawable or custom Drawable type are - * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * masked. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come * from the apk. * *

Default is enabled. */ -var SentryReplayOptions.redactAllImages: Boolean +var SentryReplayOptions.maskAllImages: Boolean @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) get() = error("Getter not supported") - set(value) = setRedactAllImages(value) + set(value) = setMaskAllImages(value) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt index 37061a5b77..2625399c99 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt @@ -3,16 +3,16 @@ package io.sentry.android.replay import android.view.View /** - * Marks this view to be redacted in session replay. + * Marks this view to be masked in session replay. */ -fun View.sentryReplayRedact() { - setTag(R.id.sentry_privacy, "redact") +fun View.sentryReplayMask() { + setTag(R.id.sentry_privacy, "mask") } /** - * Marks this view to be ignored from redaction in session. + * Marks this view to be unmasked in session replay. * All its content will be visible in the replay, use with caution. */ -fun View.sentryReplayIgnore() { - setTag(R.id.sentry_privacy, "ignore") +fun View.sentryReplayUnmask() { + setTag(R.id.sentry_privacy, "unmask") } 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 e011992cb5..e0cca55742 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 @@ -35,7 +35,7 @@ internal class ComposeTextLayout(internal val layout: TextLayoutResult, private // 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. + * This method is necessary to mask 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 @@ -69,9 +69,9 @@ internal fun LayoutNode.findPainter(): Painter? { * [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. + * but it can as well come from a network resource, so we preemptively mask it. */ -internal fun Painter.isRedactable(): Boolean { +internal fun Painter.isMaskable(): Boolean { val className = this::class.java.name return !className.contains("Vector") && !className.contains("Color") && @@ -88,11 +88,11 @@ 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. + * This method is necessary to mask text in Compose. * * We heuristically look up for classes that have a [Text] modifier, usually they all have a * `Text` string in their name, e.g. TextStringSimpleElement or TextAnnotatedStringElement. We then - * get the color from the modifier, to be able to redact it with the correct color. + * get the color from the modifier, to be able to mask it with the correct color. * * We also look up for classes that have a [Fill] modifier, usually they all have a `Fill` string in * their name, e.g. FillElement. This is necessary to workaround a Compose bug where single-line 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 1c6111c1b0..0a0656de52 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 @@ -88,9 +88,9 @@ internal fun View.isVisibleToUser(): Pair { @SuppressLint("ObsoleteSdkInt") @TargetApi(21) -internal fun Drawable?.isRedactable(): Boolean { +internal fun Drawable?.isMaskable(): Boolean { // TODO: maybe find a way how to check if the drawable is coming from the apk or loaded from network - // TODO: otherwise maybe check for the bitmap size and don't redact those that take a lot of height (e.g. a background of a whatsapp chat) + // TODO: otherwise maybe check for the bitmap size and don't mask those that take a lot of height (e.g. a background of a whatsapp chat) return when (this) { is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false is BitmapDrawable -> { 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 934454ad7f..52c2412bf8 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 @@ -21,7 +21,7 @@ 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 -import io.sentry.android.replay.util.isRedactable +import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.toRect import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode @@ -44,22 +44,22 @@ internal object ComposeViewHierarchyNode { } } - private fun LayoutNode.shouldRedact(isImage: Boolean, options: SentryOptions): Boolean { + private fun LayoutNode.shouldMask(isImage: Boolean, options: SentryOptions): Boolean { val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy) - if (sentryPrivacyModifier == "ignore") { + if (sentryPrivacyModifier == "unmask") { return false } - if (sentryPrivacyModifier == "redact") { + if (sentryPrivacyModifier == "mask") { return true } val className = getProxyClassName(isImage) - if (options.experimental.sessionReplay.ignoreViewClasses.contains(className)) { + if (options.experimental.sessionReplay.unmaskViewClasses.contains(className)) { return false } - return options.experimental.sessionReplay.redactViewClasses.contains(className) + return options.experimental.sessionReplay.maskViewClasses.contains(className) } private fun fromComposeNode( @@ -82,7 +82,7 @@ internal object ComposeViewHierarchyNode { val positionInWindow = node.coordinates.positionInWindow() return when { semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { - val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + val shouldMask = isVisible && node.shouldMask(isImage = false, options) parent?.setImportantForCaptureToAncestors(true) val textLayoutResults = mutableListOf() @@ -107,7 +107,7 @@ internal object ComposeViewHierarchyNode { elevation = (parent?.elevation ?: 0f), distance = distance, parent = parent, - shouldRedact = shouldRedact, + shouldMask = shouldMask, isImportantForContentCapture = true, isVisible = isVisible, visibleRect = visibleRect @@ -116,7 +116,7 @@ internal object ComposeViewHierarchyNode { else -> { val painter = node.findPainter() if (painter != null) { - val shouldRedact = isVisible && node.shouldRedact(isImage = true, options) + val shouldMask = isVisible && node.shouldMask(isImage = true, options) parent?.setImportantForCaptureToAncestors(true) ImageViewHierarchyNode( @@ -129,11 +129,11 @@ internal object ComposeViewHierarchyNode { parent = parent, isVisible = isVisible, isImportantForContentCapture = true, - shouldRedact = shouldRedact && painter.isRedactable(), + shouldMask = shouldMask && painter.isMaskable(), visibleRect = visibleRect ) } else { - val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + val shouldMask = isVisible && node.shouldMask(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 @@ -146,7 +146,7 @@ internal object ComposeViewHierarchyNode { elevation = (parent?.elevation ?: 0f), distance = distance, parent = parent, - shouldRedact = shouldRedact, + shouldMask = shouldMask, isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect 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 a231e4f3d2..ef05ecb029 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 @@ -9,7 +9,7 @@ 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.isMaskable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.totalPaddingTopSafe @@ -25,7 +25,7 @@ sealed class ViewHierarchyNode( /* Distance to the parent (index) */ val distance: Int, val parent: ViewHierarchyNode? = null, - val shouldRedact: Boolean = false, + val shouldMask: Boolean = false, /* Whether the node is important for content capture (=non-empty container) */ var isImportantForContentCapture: Boolean = false, val isVisible: Boolean = false, @@ -41,11 +41,11 @@ sealed class ViewHierarchyNode( elevation: Float, distance: Int, parent: ViewHierarchyNode? = null, - shouldRedact: Boolean = false, + shouldMask: Boolean = false, isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) class TextViewHierarchyNode( val layout: TextLayout? = null, @@ -59,11 +59,11 @@ sealed class ViewHierarchyNode( elevation: Float, distance: Int, parent: ViewHierarchyNode? = null, - shouldRedact: Boolean = false, + shouldMask: Boolean = false, isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) class ImageViewHierarchyNode( x: Float, @@ -73,11 +73,11 @@ sealed class ViewHierarchyNode( elevation: Float, distance: Int, parent: ViewHierarchyNode? = null, - shouldRedact: Boolean = false, + shouldMask: Boolean = false, isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) /** * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() @@ -233,8 +233,8 @@ sealed class ViewHierarchyNode( ) companion object { - private const val SENTRY_IGNORE_TAG = "sentry-ignore" - private const val SENTRY_REDACT_TAG = "sentry-redact" + private const val SENTRY_UNMASK_TAG = "sentry-unmask" + private const val SENTRY_MASK_TAG = "sentry-mask" private fun Class<*>.isAssignableFrom(set: Set): Boolean { var cls: Class<*>? = this @@ -248,29 +248,29 @@ sealed class ViewHierarchyNode( return false } - private fun View.shouldRedact(options: SentryOptions): Boolean { - if ((tag as? String)?.lowercase()?.contains(SENTRY_IGNORE_TAG) == true || - getTag(R.id.sentry_privacy) == "ignore" + private fun View.shouldMask(options: SentryOptions): Boolean { + if ((tag as? String)?.lowercase()?.contains(SENTRY_UNMASK_TAG) == true || + getTag(R.id.sentry_privacy) == "unmask" ) { return false } - if ((tag as? String)?.lowercase()?.contains(SENTRY_REDACT_TAG) == true || - getTag(R.id.sentry_privacy) == "redact" + if ((tag as? String)?.lowercase()?.contains(SENTRY_MASK_TAG) == true || + getTag(R.id.sentry_privacy) == "mask" ) { return true } - if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.ignoreViewClasses)) { + if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.unmaskViewClasses)) { return false } - return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactViewClasses) + return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.maskViewClasses) } fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { val (isVisible, visibleRect) = view.isVisibleToUser() - val shouldRedact = isVisible && view.shouldRedact(options) + val shouldMask = isVisible && view.shouldMask(options) when (view) { is TextView -> { parent?.setImportantForCaptureToAncestors(true) @@ -284,7 +284,7 @@ sealed class ViewHierarchyNode( width = view.width, height = view.height, elevation = (parent?.elevation ?: 0f) + view.elevation, - shouldRedact = shouldRedact, + shouldMask = shouldMask, distance = distance, parent = parent, isImportantForContentCapture = true, @@ -305,7 +305,7 @@ sealed class ViewHierarchyNode( parent = parent, isVisible = isVisible, isImportantForContentCapture = true, - shouldRedact = shouldRedact && view.drawable?.isRedactable() == true, + shouldMask = shouldMask && view.drawable?.isMaskable() == true, visibleRect = visibleRect ) } @@ -319,7 +319,7 @@ sealed class ViewHierarchyNode( (parent?.elevation ?: 0f) + view.elevation, distance = distance, parent = parent, - shouldRedact = shouldRedact, + shouldMask = shouldMask, isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect 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/ComposeMaskingOptionsTest.kt similarity index 67% rename from sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt rename to sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index 981e351408..e5330fa827 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/ComposeMaskingOptionsTest.kt @@ -22,10 +22,10 @@ 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 io.sentry.android.replay.sentryReplayRedact +import io.sentry.android.replay.maskAllImages +import io.sentry.android.replay.maskAllText +import io.sentry.android.replay.sentryReplayMask +import io.sentry.android.replay.sentryReplayUnmask import io.sentry.android.replay.util.ComposeTextLayout import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode @@ -43,132 +43,132 @@ import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) -class ComposeRedactionOptionsTest { +class ComposeMaskingOptionsTest { @Before fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") - ComposeRedactionOptionsActivity.textModifierApplier = null - ComposeRedactionOptionsActivity.containerModifierApplier = null + ComposeMaskingOptionsActivity.textModifierApplier = null + ComposeMaskingOptionsActivity.containerModifierApplier = null } @Test - fun `when redactAllText is set all Text nodes are redacted`() { - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when maskAllText is set all Text nodes are masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true + experimental.sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - assertTrue(textNodes.all { it.shouldRedact }) + assertTrue(textNodes.all { it.shouldMask }) // 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() + fun `when maskAllText is set to false all Text nodes are unmasked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false + experimental.sessionReplay.maskAllText = false } val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - assertTrue(textNodes.none { it.shouldRedact }) + assertTrue(textNodes.none { it.shouldMask }) } @Test - fun `when redactAllImages is set all Image nodes are redacted`() { - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when maskAllImages is set all Image nodes are masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = true + experimental.sessionReplay.maskAllImages = true } val imageNodes = activity.get().collectNodesOfType(options) assertEquals(1, imageNodes.size) // [AsyncImage] - assertTrue(imageNodes.all { it.shouldRedact }) + assertTrue(imageNodes.all { it.shouldMask }) } @Test - fun `when redactAllImages is set to false all Image nodes are ignored`() { - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when maskAllImages is set to false all Image nodes are unmasked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = false + experimental.sessionReplay.maskAllImages = false } val imageNodes = activity.get().collectNodesOfType(options) assertEquals(1, imageNodes.size) // [AsyncImage] - assertTrue(imageNodes.none { it.shouldRedact }) + assertTrue(imageNodes.none { it.shouldMask }) } @Test - fun `when sentry-redact modifier is set redacts the node`() { - ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayRedact() } - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when sentry-mask modifier is set masks the node`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayMask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false + experimental.sessionReplay.maskAllText = 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) + assertTrue(it.shouldMask) } else { - assertFalse(it.shouldRedact) + assertFalse(it.shouldMask) } } } @Test - fun `when sentry-ignore modifier is set ignores the node`() { - ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayIgnore() } - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when sentry-unmask modifier is set unmasks the node`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayUnmask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true + experimental.sessionReplay.maskAllText = 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) + assertFalse(it.shouldMask) } else { - assertTrue(it.shouldRedact) + assertTrue(it.shouldMask) } } } @Test - fun `when view is not visible, does not redact the view`() { - ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.semantics { invisibleToUser() } } - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when view is not visible, does not mask the view`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.semantics { invisibleToUser() } } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true + experimental.sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) textNodes.forEach { if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { - assertFalse(it.shouldRedact) + assertFalse(it.shouldMask) } else { - assertTrue(it.shouldRedact) + assertTrue(it.shouldMask) } } } @Test - fun `when a container view is ignored its children are not ignored`() { - ComposeRedactionOptionsActivity.containerModifierApplier = { Modifier.sentryReplayIgnore() } - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when a container view is unmasked its children are not unmasked`() { + ComposeMaskingOptionsActivity.containerModifierApplier = { Modifier.sentryReplayUnmask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions() @@ -176,9 +176,9 @@ class ComposeRedactionOptionsTest { 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 }) + assertTrue(imageNodes.all { it.shouldMask }) + assertTrue(textNodes.all { it.shouldMask }) + assertTrue(genericNodes.none { it.shouldMask }) } private inline fun Activity.collectNodesOfType(options: SentryOptions): List { @@ -197,7 +197,7 @@ class ComposeRedactionOptionsTest { } } -private class ComposeRedactionOptionsActivity : ComponentActivity() { +private class ComposeMaskingOptionsActivity : ComponentActivity() { companion object { var textModifierApplier: (() -> Modifier)? = null diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt new file mode 100644 index 0000000000..4a40e0a915 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt @@ -0,0 +1,278 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.RadioButton +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.maskAllImages +import io.sentry.android.replay.maskAllText +import io.sentry.android.replay.sentryReplayMask +import io.sentry.android.replay.sentryReplayUnmask +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +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 + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class MaskingOptionsTest { + + @BeforeTest + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + } + + @Test + fun `when maskAllText is set all TextView nodes are masked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertTrue(textNode.shouldMask) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertTrue(radioButtonNode.shouldMask) + } + + @Test + fun `when maskAllText is set to false all TextView nodes are unmasked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertFalse(textNode.shouldMask) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertFalse(radioButtonNode.shouldMask) + } + + @Test + fun `when maskAllImages is set all ImageView nodes are masked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = true + } + + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertTrue(imageNode.shouldMask) + } + + @Test + fun `when maskAllImages is set to false all ImageView nodes are unmasked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = false + } + + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertFalse(imageNode.shouldMask) + } + + @Test + fun `when sentry-mask tag is set mask the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + MaskingOptionsActivity.textView!!.tag = "sentry-mask" + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldMask) + } + + @Test + fun `when sentry-unmask tag is set unmasks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.tag = "sentry-unmask" + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when sentry-privacy tag is set to mask masks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + MaskingOptionsActivity.textView!!.sentryReplayMask() + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldMask) + } + + @Test + fun `when sentry-privacy tag is set to unmask unmasks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.sentryReplayUnmask() + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when view is not visible, does not mask the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.visibility = View.GONE + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when added to mask list masks custom view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskViewClasses.add(CustomView::class.java.canonicalName) + } + + val customViewNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.customView!!, null, 0, options) + + assertTrue(customViewNode.shouldMask) + } + + @Test + fun `when subclass is added to ignored classes ignores all instances of that class`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true // all TextView subclasses + experimental.sessionReplay.unmaskViewClasses.add(RadioButton::class.java.canonicalName) + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode.shouldMask) + assertFalse(radioButtonNode.shouldMask) + } + + @Test + fun `when a container view is ignored its children are not ignored`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.unmaskViewClasses.add(LinearLayout::class.java.canonicalName) + } + + val linearLayoutNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertFalse(linearLayoutNode.shouldMask) + assertTrue(textNode.shouldMask) + assertTrue(imageNode.shouldMask) + } +} + +private class CustomView(context: Context) : View(context) { + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(Color.BLACK) + } +} + +private class MaskingOptionsActivity : Activity() { + + companion object { + var textView: TextView? = null + var radioButton: RadioButton? = null + var imageView: ImageView? = null + var customView: CustomView? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + textView = TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + imageView = ImageView(this).apply { + setImageDrawable(Drawable.createFromPath(image.path)) + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(imageView) + + radioButton = RadioButton(this).apply { + text = "Radio Button" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(radioButton) + + customView = CustomView(this).apply { + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(customView) + + setContentView(linearLayout) + } +} 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 deleted file mode 100644 index c1a50f7a62..0000000000 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt +++ /dev/null @@ -1,278 +0,0 @@ -package io.sentry.android.replay.viewhierarchy - -import android.app.Activity -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.view.View -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.LinearLayout.LayoutParams -import android.widget.RadioButton -import android.widget.TextView -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.SentryOptions -import io.sentry.android.replay.redactAllImages -import io.sentry.android.replay.redactAllText -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.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 - -@RunWith(AndroidJUnit4::class) -@Config(sdk = [30]) -class RedactionOptionsTest { - - @BeforeTest - fun setup() { - System.setProperty("robolectric.areWindowsMarkedVisible", "true") - } - - @Test - fun `when redactAllText is set all TextView nodes are redacted`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - 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) - - assertTrue(radioButtonNode is TextViewHierarchyNode) - assertTrue(radioButtonNode.shouldRedact) - } - - @Test - fun `when redactAllText is set to false all TextView nodes are ignored`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false - } - - 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) - - assertTrue(radioButtonNode is TextViewHierarchyNode) - assertFalse(radioButtonNode.shouldRedact) - } - - @Test - fun `when redactAllImages is set all ImageView nodes are redacted`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = true - } - - val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) - - assertTrue(imageNode is ImageViewHierarchyNode) - assertTrue(imageNode.shouldRedact) - } - - @Test - fun `when redactAllImages is set to false all ImageView nodes are ignored`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = false - } - - val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) - - assertTrue(imageNode is ImageViewHierarchyNode) - assertFalse(imageNode.shouldRedact) - } - - @Test - fun `when sentry-redact tag is set redacts the view`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false - } - - 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(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - 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(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false - } - - 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(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - 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(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - 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(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactViewClasses.add(CustomView::class.java.canonicalName) - } - - 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(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(RedactionOptionsActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) - - assertTrue(textNode.shouldRedact) - assertFalse(radioButtonNode.shouldRedact) - } - - @Test - fun `when a container view is ignored its children are not ignored`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.ignoreViewClasses.add(LinearLayout::class.java.canonicalName) - } - - 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) - assertTrue(imageNode.shouldRedact) - } -} - -private class CustomView(context: Context) : View(context) { - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - canvas.drawColor(Color.BLACK) - } -} - -private class RedactionOptionsActivity : Activity() { - - companion object { - var textView: TextView? = null - var radioButton: RadioButton? = null - var imageView: ImageView? = null - var customView: CustomView? = null - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val linearLayout = LinearLayout(this).apply { - setBackgroundColor(android.R.color.white) - orientation = LinearLayout.VERTICAL - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - } - - textView = TextView(this).apply { - text = "Hello, World!" - layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) - } - linearLayout.addView(textView) - - val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! - imageView = ImageView(this).apply { - setImageDrawable(Drawable.createFromPath(image.path)) - layoutParams = LayoutParams(50, 50).apply { - setMargins(0, 16, 0, 0) - } - } - linearLayout.addView(imageView) - - radioButton = RadioButton(this).apply { - text = "Radio Button" - layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - setMargins(0, 16, 0, 0) - } - } - linearLayout.addView(radioButton) - - customView = CustomView(this).apply { - layoutParams = LayoutParams(50, 50).apply { - setMargins(0, 16, 0, 0) - } - } - linearLayout.addView(customView) - - setContentView(linearLayout) - } -} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 703685d6f0..80ad5328b5 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 23ecd893c3..88c845acaa 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 @@ -36,7 +36,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.android.replay.sentryReplayUnmask import io.sentry.compose.SentryTraced import io.sentry.compose.withSentryObservableEffect import io.sentry.samples.android.GithubAPI @@ -145,7 +145,7 @@ fun Github( .testTag("button_list_repos_async") .padding(top = 32.dp) ) { - Text("Make Request", modifier = Modifier.sentryReplayIgnore()) + Text("Make Request", modifier = Modifier.sentryReplayUnmask()) } } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5f8b061acf..da036d225e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2713,23 +2713,23 @@ public final class io/sentry/SentryReplayOptions { public static final field TEXT_VIEW_CLASS_NAME Ljava/lang/String; public fun ()V public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun addIgnoreViewClass (Ljava/lang/String;)V - public fun addRedactViewClass (Ljava/lang/String;)V + public fun addMaskViewClass (Ljava/lang/String;)V + public fun addUnmaskViewClass (Ljava/lang/String;)V public fun getErrorReplayDuration ()J public fun getFrameRate ()I - public fun getIgnoreViewClasses ()Ljava/util/Set; + public fun getMaskViewClasses ()Ljava/util/Set; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; - public fun getRedactViewClasses ()Ljava/util/Set; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J + public fun getUnmaskViewClasses ()Ljava/util/Set; public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z + public fun setMaskAllImages (Z)V + public fun setMaskAllText (Z)V public fun setOnErrorSampleRate (Ljava/lang/Double;)V public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V - public fun setRedactAllImages (Z)V - public fun setRedactAllText (Z)V public fun setSessionSampleRate (Ljava/lang/Double;)V } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 7656b088a1..35e6aba718 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -52,19 +52,19 @@ public enum SentryReplayQuality { private @Nullable Double onErrorSampleRate; /** - * Redact all views with the specified class names. The class name is the fully qualified class - * name of the view, e.g. android.widget.TextView. The subclasses of the specified classes will be - * redacted as well. + * Mask all views with the specified class names. The class name is the fully qualified class name + * of the view, e.g. android.widget.TextView. The subclasses of the specified classes will be + * masked as well. * *

If you're using an obfuscation tool, make sure to add the respective proguard rules to keep * the class names. * *

Default is empty. */ - private Set redactViewClasses = new CopyOnWriteArraySet<>(); + private Set maskViewClasses = new CopyOnWriteArraySet<>(); /** - * Ignore all views with the specified class names from redaction. The class name is the fully + * Ignore all views with the specified class names from masking. The class name is the fully * qualified class name of the view, e.g. android.widget.TextView. The subclasses of the specified * classes will be ignored as well. * @@ -73,7 +73,7 @@ public enum SentryReplayQuality { * *

Default is empty. */ - private Set ignoreViewClasses = new CopyOnWriteArraySet<>(); + private Set unmaskViewClasses = new CopyOnWriteArraySet<>(); /** * Defines the quality of the session replay. The higher the quality, the more accurate the replay @@ -97,8 +97,8 @@ public enum SentryReplayQuality { private long sessionDuration = 60 * 60 * 1000L; public SentryReplayOptions() { - setRedactAllText(true); - setRedactAllImages(true); + setMaskAllText(true); + setMaskAllImages(true); } public SentryReplayOptions( @@ -147,55 +147,55 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { } /** - * Redact all text content. Draws a rectangle of text bounds with text color on top. By default - * only views extending TextView are redacted. + * Mask all text content. Draws a rectangle of text bounds with text color on top. By default only + * views extending TextView are masked. * *

Default is enabled. */ - public void setRedactAllText(final boolean redactAllText) { - if (redactAllText) { - addRedactViewClass(TEXT_VIEW_CLASS_NAME); - ignoreViewClasses.remove(TEXT_VIEW_CLASS_NAME); + public void setMaskAllText(final boolean maskAllText) { + if (maskAllText) { + addMaskViewClass(TEXT_VIEW_CLASS_NAME); + unmaskViewClasses.remove(TEXT_VIEW_CLASS_NAME); } else { - addIgnoreViewClass(TEXT_VIEW_CLASS_NAME); - redactViewClasses.remove(TEXT_VIEW_CLASS_NAME); + addUnmaskViewClass(TEXT_VIEW_CLASS_NAME); + maskViewClasses.remove(TEXT_VIEW_CLASS_NAME); } } /** - * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * Mask all image content. Draws a rectangle of image bounds with image's dominant color on top. * By default only views extending ImageView with BitmapDrawable or custom Drawable type are - * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * masked. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come * from the apk. * *

Default is enabled. */ - public void setRedactAllImages(final boolean redactAllImages) { - if (redactAllImages) { - addRedactViewClass(IMAGE_VIEW_CLASS_NAME); - ignoreViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + public void setMaskAllImages(final boolean maskAllImages) { + if (maskAllImages) { + addMaskViewClass(IMAGE_VIEW_CLASS_NAME); + unmaskViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } else { - addIgnoreViewClass(IMAGE_VIEW_CLASS_NAME); - redactViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + addUnmaskViewClass(IMAGE_VIEW_CLASS_NAME); + maskViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } } @NotNull - public Set getRedactViewClasses() { - return this.redactViewClasses; + public Set getMaskViewClasses() { + return this.maskViewClasses; } - public void addRedactViewClass(final @NotNull String className) { - this.redactViewClasses.add(className); + public void addMaskViewClass(final @NotNull String className) { + this.maskViewClasses.add(className); } @NotNull - public Set getIgnoreViewClasses() { - return this.ignoreViewClasses; + public Set getUnmaskViewClasses() { + return this.unmaskViewClasses; } - public void addIgnoreViewClass(final @NotNull String className) { - this.ignoreViewClasses.add(className); + public void addUnmaskViewClass(final @NotNull String className) { + this.unmaskViewClasses.add(className); } @ApiStatus.Internal From 724865df09f56e7e4ee87fb8ffa7b89536d8f750 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 2 Oct 2024 12:01:03 +0200 Subject: [PATCH 10/19] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9124e309..572e71fb3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - `options.experimental.sessionReplay.errorSampleRate` was renamed to `options.experimental.sessionReplay.onErrorSampleRate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) - Manifest option `io.sentry.session-replay.error-sample-rate` was renamed to `io.sentry.session-replay.on-error-sample-rate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) +- Change `redactAllText` and `redactAllImages` to `maskAllText` and `maskAllImages` ([#3741](https://github.com/getsentry/sentry-java/pull/3741)) ## 7.14.0 From b7489d793d01e61666a84de6bcc38099fa6cf20a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 8 Oct 2024 20:35:52 +0200 Subject: [PATCH 11/19] [SR] Mask web and video views (#3775) --- CHANGELOG.md | 1 + sentry-android-replay/proguard-rules.pro | 3 +++ .../sentry/android/replay/video/SimpleVideoEncoder.kt | 2 +- sentry/api/sentry.api | 5 +++++ .../src/main/java/io/sentry/SentryReplayOptions.java | 11 +++++++++++ 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 572e71fb3a..c1a9456edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - 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.sentryReplayMask()` and `Modifier.sentryReplayUnmask()` modifiers +- Session Replay: Mask `WebView`, `VideoView` and `androidx.media3.ui.PlayerView` by default ([#3775](https://github.com/getsentry/sentry-java/pull/3775)) ### Fixes diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index c537ff7399..c975793b82 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -18,3 +18,6 @@ # Rules to detect a compose view to parse its hierarchy -dontwarn androidx.compose.ui.platform.AndroidComposeView -keepnames class androidx.compose.ui.platform.AndroidComposeView +# Rules to detect a media player view to later mask it +-dontwarn androidx.media3.ui.PlayerView +-keepnames class androidx.media3.ui.PlayerView diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index baf521a2e6..211decc098 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -136,7 +136,7 @@ internal class SimpleVideoEncoder( ) format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate.toFloat()) - format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, -1) // use -1 to force always non-key frames, meaning only partial updates to save the video size + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 6) // use 6 to force non-key frames, meaning only partial updates to save the video size. Every 6th second is a key frame, which is useful for buffer mode format } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index da036d225e..bfd644c64f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2709,8 +2709,13 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent } public final class io/sentry/SentryReplayOptions { + public static final field ANDROIDX_MEDIA_VIEW_CLASS_NAME Ljava/lang/String; + public static final field EXOPLAYER_CLASS_NAME Ljava/lang/String; + public static final field EXOPLAYER_STYLED_CLASS_NAME Ljava/lang/String; public static final field IMAGE_VIEW_CLASS_NAME Ljava/lang/String; public static final field TEXT_VIEW_CLASS_NAME Ljava/lang/String; + public static final field VIDEO_VIEW_CLASS_NAME Ljava/lang/String; + public static final field WEB_VIEW_CLASS_NAME Ljava/lang/String; public fun ()V public fun (Ljava/lang/Double;Ljava/lang/Double;)V public fun addMaskViewClass (Ljava/lang/String;)V diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 35e6aba718..e2931371c7 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -11,6 +11,12 @@ public final class SentryReplayOptions { public static final String TEXT_VIEW_CLASS_NAME = "android.widget.TextView"; public static final String IMAGE_VIEW_CLASS_NAME = "android.widget.ImageView"; + public static final String WEB_VIEW_CLASS_NAME = "android.webkit.WebView"; + public static final String VIDEO_VIEW_CLASS_NAME = "android.widget.VideoView"; + public static final String ANDROIDX_MEDIA_VIEW_CLASS_NAME = "androidx.media3.ui.PlayerView"; + public static final String EXOPLAYER_CLASS_NAME = "com.google.android.exoplayer2.ui.PlayerView"; + public static final String EXOPLAYER_STYLED_CLASS_NAME = + "com.google.android.exoplayer2.ui.StyledPlayerView"; public enum SentryReplayQuality { /** Video Scale: 80% Bit Rate: 50.000 */ @@ -99,6 +105,11 @@ public enum SentryReplayQuality { public SentryReplayOptions() { setMaskAllText(true); setMaskAllImages(true); + maskViewClasses.add(WEB_VIEW_CLASS_NAME); + maskViewClasses.add(VIDEO_VIEW_CLASS_NAME); + maskViewClasses.add(ANDROIDX_MEDIA_VIEW_CLASS_NAME); + maskViewClasses.add(EXOPLAYER_CLASS_NAME); + maskViewClasses.add(EXOPLAYER_STYLED_CLASS_NAME); } public SentryReplayOptions( From f9420e060d801b49b29a33e1a6dafda892b26fd2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 8 Oct 2024 20:41:58 +0200 Subject: [PATCH 12/19] 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 @@ + + + From d7504c7cd036d2cdb61e189cdc1b692e5cb56a3a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 8 Oct 2024 20:48:38 +0200 Subject: [PATCH 13/19] Add missing proguard rules --- sentry-android-replay/proguard-rules.pro | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index c975793b82..378c0964f8 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -21,3 +21,8 @@ # Rules to detect a media player view to later mask it -dontwarn androidx.media3.ui.PlayerView -keepnames class androidx.media3.ui.PlayerView +# Rules to detect a ExoPlayer view to later mask it +-dontwarn com.google.android.exoplayer2.ui.PlayerView +-keepnames class com.google.android.exoplayer2.ui.PlayerView +-dontwarn com.google.android.exoplayer2.ui.StyledPlayerView +-keepnames class com.google.android.exoplayer2.ui.StyledPlayerView From 50c5dfaf25f6d9096d9bd731f6c9aaac4dc00735 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 8 Oct 2024 22:02:03 +0200 Subject: [PATCH 14/19] formatting --- .../src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 1 - 1 file changed, 1 deletion(-) 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 35b76a0e76..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 @@ -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.ViewTreeObserver From c546d6ab8cec18007ae1156c6f121556d1070342 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 9 Oct 2024 13:17:26 +0200 Subject: [PATCH 15/19] Faster boundsInWindow for compose --- .../api/sentry-android-replay.api | 8 ++ .../io/sentry/android/replay/util/Nodes.kt | 93 +++++++++++++++++-- .../viewhierarchy/ComposeViewHierarchyNode.kt | 22 +++-- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 4b4c59b9a2..7a73fa9a89 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -191,6 +191,14 @@ public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Wi public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; } +public final class io/sentry/android/replay/util/NodesKt { + public static final fun fastCoerceAtLeast (FF)F + public static final fun fastCoerceAtMost (FF)F + public static final fun fastCoerceIn (FFF)F + public static final fun fastMaxOf (FFFF)F + public static final fun fastMinOf (FFFF)F +} + public abstract interface class io/sentry/android/replay/util/TextLayout { public abstract fun getDominantTextColor ()Ljava/lang/Integer; public abstract fun getEllipsisCount (I)I 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 e011992cb5..12152f50cb 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 @@ -3,9 +3,11 @@ package io.sentry.android.replay.util import android.graphics.Rect +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.text.TextLayoutResult import kotlin.math.roundToInt @@ -78,13 +80,6 @@ internal fun Painter.isRedactable(): Boolean { !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) /** @@ -125,3 +120,87 @@ internal fun LayoutNode.findTextAttributes(): TextAttributes { } return TextAttributes(color, hasFillModifier) } + +/** + * Returns the smaller of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.minOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMinOf(a: Float, b: Float, c: Float, d: Float): Float { + return minOf(a, minOf(b, minOf(c, d))) +} + +/** + * Returns the largest of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.maxOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMaxOf(a: Float, b: Float, c: Float, d: Float): Float { + return maxOf(a, maxOf(b, maxOf(c, d))) +} + +/** + * Returns this float value clamped in the inclusive range defined by [minimumValue] and + * [maximumValue]. Unlike [Float.coerceIn], the range is not validated: the caller must ensure that + * [minimumValue] is less than [maximumValue]. + */ +private inline fun Float.fastCoerceIn(minimumValue: Float, maximumValue: Float) = + this.fastCoerceAtLeast(minimumValue).fastCoerceAtMost(maximumValue) + +/** Ensures that this value is not less than the specified [minimumValue]. */ +private inline fun Float.fastCoerceAtLeast(minimumValue: Float): Float { + return if (this < minimumValue) minimumValue else this +} + +/** Ensures that this value is not greater than the specified [maximumValue]. */ +private inline fun Float.fastCoerceAtMost(maximumValue: Float): Float { + return if (this > maximumValue) maximumValue else this +} + +/** + * A faster copy of https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187 + * + * Since we traverse the tree from the root, we don't need to find it again from the leaf node and + * just pass it as an argument. + * + * @return boundaries of this layout relative to the window's origin. + */ +internal fun LayoutCoordinates.boundsInWindow(root: LayoutCoordinates?): Rect { + root ?: return Rect() + + val rootWidth = root.size.width.toFloat() + val rootHeight = root.size.height.toFloat() + + val bounds = root.localBoundingBoxOf(this) + val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) + val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) + val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) + val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) + + if (boundsLeft == boundsRight || boundsTop == boundsBottom) { + return Rect() + } + + val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) + val topRight = root.localToWindow(Offset(boundsRight, boundsTop)) + val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom)) + val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom)) + + val topLeftX = topLeft.x + val topRightX = topRight.x + val bottomLeftX = bottomLeft.x + val bottomRightX = bottomRight.x + + val left = fastMinOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + val right = fastMaxOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + + val topLeftY = topLeft.y + val topRightY = topRight.y + val bottomLeftY = bottomLeft.y + val bottomRightY = bottomRight.y + + val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) +} 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 934454ad7f..c611b91b47 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 @@ -6,7 +6,8 @@ import android.annotation.TargetApi import android.view.View import androidx.compose.ui.graphics.isUnspecified import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.node.Owner @@ -19,11 +20,11 @@ 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.boundsInWindow 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 @@ -62,10 +63,13 @@ internal object ComposeViewHierarchyNode { return options.experimental.sessionReplay.redactViewClasses.contains(className) } + private var _rootCoordinates: LayoutCoordinates? = null + private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, + isComposeRoot: Boolean, options: SentryOptions ): ViewHierarchyNode? { val isInTree = node.isPlaced && node.isAttached @@ -73,8 +77,12 @@ internal object ComposeViewHierarchyNode { return null } + if (isComposeRoot) { + _rootCoordinates = node.coordinates.findRootCoordinates() + } + val semantics = node.collapsedSemantics - val visibleRect = node.coordinates.boundsInWindow().toRect() + val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates) val isVisible = !node.outerCoordinator.isTransparent() && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && visibleRect.height() > 0 && visibleRect.width() > 0 @@ -167,7 +175,7 @@ internal object ComposeViewHierarchyNode { try { val rootNode = (view as? Owner)?.root ?: return false - rootNode.traverse(parent, options) + rootNode.traverse(parent, isComposeRoot = true, options) } catch (e: Throwable) { options.logger.log( SentryLevel.ERROR, @@ -185,7 +193,7 @@ internal object ComposeViewHierarchyNode { return true } - private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, options: SentryOptions) { + private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, isComposeRoot: Boolean, options: SentryOptions) { val children = this.children if (children.isEmpty()) { return @@ -194,10 +202,10 @@ internal object ComposeViewHierarchyNode { val childNodes = ArrayList(children.size) for (index in children.indices) { val child = children[index] - val childNode = fromComposeNode(child, parentNode, index, options) + val childNode = fromComposeNode(child, parentNode, index, isComposeRoot, options) if (childNode != null) { childNodes.add(childNode) - child.traverse(childNode, options) + child.traverse(childNode, isComposeRoot = false, options) } } parentNode.children = childNodes From 6c6ff6a9d5c4774d73d50bf0ea882ed8c184fb9d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 9 Oct 2024 13:32:56 +0200 Subject: [PATCH 16/19] api dump --- sentry-android-replay/api/sentry-android-replay.api | 8 -------- 1 file changed, 8 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 7a73fa9a89..4b4c59b9a2 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -191,14 +191,6 @@ public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Wi public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; } -public final class io/sentry/android/replay/util/NodesKt { - public static final fun fastCoerceAtLeast (FF)F - public static final fun fastCoerceAtMost (FF)F - public static final fun fastCoerceIn (FFF)F - public static final fun fastMaxOf (FFFF)F - public static final fun fastMinOf (FFFF)F -} - public abstract interface class io/sentry/android/replay/util/TextLayout { public abstract fun getDominantTextColor ()Ljava/lang/Integer; public abstract fun getEllipsisCount (I)I From a16a07cd807d8993273ce6b189c7e5fb2e48b951 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 9 Oct 2024 16:48:38 +0200 Subject: [PATCH 17/19] Dont use liveliterals --- sentry-android-replay/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index ad7679bae4..15713bb6f4 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -32,6 +32,7 @@ android { composeOptions { kotlinCompilerExtensionVersion = Config.androidComposeCompilerVersion + useLiveLiterals = false } buildTypes { From 749ae0c4a2c7b2c7d98e025e73faaa517a41d9fd Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 9 Oct 2024 17:24:00 +0200 Subject: [PATCH 18/19] Remove redundant test --- .../ComposeRedactionOptionsTest.kt | 240 ------------------ 1 file changed, 240 deletions(-) delete mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt 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 deleted file mode 100644 index 981e351408..0000000000 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt +++ /dev/null @@ -1,240 +0,0 @@ -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.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.platform.testTag -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 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 { - - @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 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( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - .then(containerModifierApplier?.invoke() ?: Modifier) - ) { - AsyncImage( - model = Uri.fromFile(File(image.toURI())), - contentDescription = null, - modifier = Modifier.padding(vertical = 16.dp) - ) - TextField( - value = TextFieldValue("Placeholder"), - onValueChange = { _ -> } - ) - Text("Random repo") - Button( - onClick = {}, - modifier = Modifier - .testTag("button_list_repos_async") - .padding(top = 32.dp) - ) { - Text("Make Request", modifier = Modifier.then(textModifierApplier?.invoke() ?: Modifier)) - } - } - } - } -} From 0fd2b53a366ef5b07ae301b2dfa43e75f1744bd4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 9 Oct 2024 17:59:33 +0200 Subject: [PATCH 19/19] Increase timeout in failing test --- .../src/test/java/io/sentry/android/core/SentryAndroidTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 6ba69ffdcb..d75e0f88a2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -440,7 +440,7 @@ class SentryAndroidTest { .untilTrue(asserted) // assert that persisted values have changed - options.executorService.close(5000L) // finalizes all enqueued persisting tasks + options.executorService.close(10000L) // finalizes all enqueued persisting tasks assertEquals( "TestActivity", PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java)