diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71190dc668..fc4c2d1009 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))
- Fix potential ANRs due to `Calendar.getInstance` usage in Breadcrumbs constructor ([#3736](https://github.com/getsentry/sentry-java/pull/3736))
- Fix potential ANRs due to default integrations ([#3778](https://github.com/getsentry/sentry-java/pull/3778))
diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt
index f74fcb4953..8e4b6832fb 100644
--- a/buildSrc/src/main/java/Config.kt
+++ b/buildSrc/src/main/java/Config.kt
@@ -147,8 +147,11 @@ 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"
+
+ 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"
val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.8.2"
diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api
index 1c08379a49..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
@@ -26,6 +28,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
@@ -34,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
@@ -50,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
@@ -78,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
@@ -103,6 +113,12 @@ 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 $stable I
+ 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
@@ -116,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;
}
@@ -130,6 +148,19 @@ 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 static final field $stable I
+ 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 +191,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
@@ -169,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
@@ -178,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
@@ -195,6 +239,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
}
@@ -204,20 +249,23 @@ 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 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 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;
- 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/build.gradle.kts b/sentry-android-replay/build.gradle.kts
index 2e74641268..15713bb6f4 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")
@@ -25,9 +26,20 @@ android {
buildConfigField("String", "VERSION_NAME", "\"${project.version}\"")
}
+ buildFeatures {
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = Config.androidComposeCompilerVersion
+ useLiveLiterals = false
+ }
+
buildTypes {
getByName("debug")
- getByName("release")
+ getByName("release") {
+ consumerProguardFiles("proguard-rules.pro")
+ }
}
kotlinOptions {
@@ -65,6 +77,7 @@ kotlin {
dependencies {
api(projects.sentry)
+ compileOnly(Config.Libs.composeUiReplay)
implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION))
// tests
@@ -77,9 +90,19 @@ dependencies {
testImplementation(Config.TestLibs.mockitoKotlin)
testImplementation(Config.TestLibs.mockitoInline)
testImplementation(Config.TestLibs.awaitility)
+ 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 {
// 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..445c89b526 100644
--- a/sentry-android-replay/proguard-rules.pro
+++ b/sentry-android-replay/proguard-rules.pro
@@ -1,3 +1,20 @@
# Uncomment this to preserve the line number information for
# debugging stack traces.
-keepattributes SourceFile,LineNumberTable
+
+# Rules to detect Images/Icons and redact 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
+-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/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 0ea3fad6ab..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,10 +13,8 @@ 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
import android.view.ViewTreeObserver
import android.view.WindowManager
import io.sentry.SentryLevel.DEBUG
@@ -25,10 +23,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.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
@@ -102,7 +100,6 @@ internal class ScreenshotRecorder(
Bitmap.Config.ARGB_8888
)
- val timeStart = System.nanoTime()
// postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible
mainLooperHandler.post {
try {
@@ -117,6 +114,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()
@@ -124,9 +122,7 @@ internal class ScreenshotRecorder(
}
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
- root.traverse(viewHierarchy)
- val timeEnd = System.nanoTime()
- Log.e("TIME", String.format("%.2f", ((timeEnd - timeStart) / 1_000_000.0)))
+ root.traverse(viewHierarchy, options)
recorder.submitSafely(options, "screenshot_recorder.redact") {
val canvas = Canvas(bitmap)
@@ -147,7 +143,7 @@ internal class ScreenshotRecorder(
}
is TextViewHierarchyNode -> {
- val textColor = node.layout.dominantTextColor
+ val textColor = node.layout?.dominantTextColor
?: node.dominantColor
?: Color.BLACK
node.layout.getVisibleRects(
@@ -206,6 +202,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?) {
@@ -255,28 +253,6 @@ internal class ScreenshotRecorder(
return singlePixelBitmap.getPixel(0, 0)
}
- private fun View.traverse(parentNode: ViewHierarchyNode) {
- if (this !is ViewGroup) {
- 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/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt
new file mode 100644
index 0000000000..12152f50cb
--- /dev/null
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt
@@ -0,0 +1,206 @@
+@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.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
+
+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)
+}
+
+// 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.
+ *
+ * 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")
+}
+
+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
+ 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)
+}
+
+/**
+ * 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/util/TextLayout.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt
new file mode 100644
index 0000000000..cd07c6d170
--- /dev/null
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt
@@ -0,0 +1,21 @@
+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 86c75f2e9d..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
*/
@@ -65,19 +101,20 @@ 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)
}
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)
@@ -105,32 +142,39 @@ 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
+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
- val spans = (text as Spanned).getSpans(0, text.length, ForegroundColorSpan::class.java)
+ 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 = (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
+ // 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(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)
}
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..c611b91b47
--- /dev/null
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt
@@ -0,0 +1,213 @@
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals
+
+package io.sentry.android.replay.viewhierarchy
+
+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.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
+import androidx.compose.ui.semantics.SemanticsActions
+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.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.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 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 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
+ }
+
+ 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
+ if (!isInTree) {
+ return null
+ }
+
+ if (isComposeRoot) {
+ _rootCoordinates = node.coordinates.findRootCoordinates()
+ }
+
+ val semantics = node.collapsedSemantics
+ val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates)
+ 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()
+ return when {
+ semantics?.contains(SemanticsProperties.Text) == true || isEditable -> {
+ val shouldRedact = isVisible && node.shouldRedact(isImage = false, options)
+
+ parent?.setImportantForCaptureToAncestors(true)
+ val textLayoutResults = mutableListOf()
+ 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 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,
+ height = node.height,
+ elevation = (parent?.elevation ?: 0f),
+ distance = distance,
+ parent = parent,
+ shouldRedact = shouldRedact,
+ isImportantForContentCapture = true,
+ isVisible = isVisible,
+ visibleRect = visibleRect
+ )
+ }
+ 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)
+
+ // 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,
+ width = node.width,
+ height = node.height,
+ elevation = (parent?.elevation ?: 0f),
+ distance = distance,
+ parent = parent,
+ shouldRedact = shouldRedact,
+ isImportantForContentCapture = false, /* will be set by children */
+ isVisible = isVisible,
+ visibleRect = visibleRect
+ )
+ }
+ }
+ }
+ }
+
+ fun fromView(view: View, parent: ViewHierarchyNode?, options: SentryOptions): Boolean {
+ if (!view::class.java.name.contains("AndroidComposeView")) {
+ return false
+ }
+
+ if (parent == null) {
+ return false
+ }
+
+ try {
+ val rootNode = (view as? Owner)?.root ?: return false
+ rootNode.traverse(parent, isComposeRoot = true, options)
+ } catch (e: Throwable) {
+ 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()
+ )
+ return false
+ }
+
+ return true
+ }
+
+ private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, isComposeRoot: Boolean, 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 childNode = fromComposeNode(child, parentNode, index, isComposeRoot, options)
+ if (childNode != null) {
+ childNodes.add(childNode)
+ child.traverse(childNode, isComposeRoot = false, options)
+ }
+ }
+ parentNode.children = childNodes
+ }
+}
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..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,14 +2,16 @@ 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
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 +48,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 +79,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 +233,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"
@@ -274,9 +273,9 @@ sealed class ViewHierarchyNode(
val shouldRedact = isVisible && view.shouldRedact(options)
when (view) {
is TextView -> {
- parent.setImportantForCaptureToAncestors(true)
+ parent?.setImportantForCaptureToAncestors(true)
return TextViewHierarchyNode(
- layout = view.layout,
+ layout = view.layout?.let { AndroidTextLayout(it) },
dominantColor = view.currentTextColor.toOpaque(),
paddingLeft = view.totalPaddingLeft,
paddingTop = view.totalPaddingTopSafe,
@@ -295,7 +294,7 @@ sealed class ViewHierarchyNode(
}
is ImageView -> {
- parent.setImportantForCaptureToAncestors(true)
+ parent?.setImportantForCaptureToAncestors(true)
return ImageViewHierarchyNode(
x = view.x,
y = view.y,
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
new file mode 100644
index 0000000000..981e351408
--- /dev/null
+++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt
@@ -0,0 +1,240 @@
+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))
+ }
+ }
+ }
+ }
+}
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..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
@@ -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,21 +32,21 @@ import kotlin.test.assertTrue
@Config(sdk = [30])
class RedactionOptionsTest {
- @Before
+ @BeforeTest
fun setup() {
System.setProperty("robolectric.areWindowsMarkedVisible", "true")
}
@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
diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts
index a8d8897519..204ef83fc2 100644
--- a/sentry-samples/sentry-samples-android/build.gradle.kts
+++ b/sentry-samples/sentry-samples-android/build.gradle.kts
@@ -132,6 +132,7 @@ dependencies {
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/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
index 997fa5ff55..703685d6f0 100644
--- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
+++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
@@ -166,6 +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..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
@@ -5,6 +5,7 @@ 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
@@ -22,7 +23,10 @@ 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.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 +35,13 @@ 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.android.replay.sentryReplayIgnore
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 +116,17 @@ fun Github(
modifier = Modifier
.fillMaxSize()
) {
+ Image(
+ painter = painterResource(IR.drawable.sentry_glyph),
+ 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 ->
@@ -127,7 +145,7 @@ fun Github(
.testTag("button_list_repos_async")
.padding(top = 32.dp)
) {
- Text("Make Request")
+ Text("Make Request", modifier = Modifier.sentryReplayIgnore())
}
}
}
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 @@
+
+
+