(
+ name = "SentryPrivacy",
+ mergePolicy = { parentValue, _ -> parentValue }
+ )
+}
+
+public fun Modifier.sentryReplayMask(): Modifier {
+ return semantics(
+ properties = {
+ this[SentryPrivacy] = "mask"
+ }
+ )
+}
+
+public fun Modifier.sentryReplayUnmask(): Modifier {
+ return semantics(
+ properties = {
+ this[SentryPrivacy] = "unmask"
+ }
+ )
+}
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt
index b2b78600c05..a449d3843ac 100644
--- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt
@@ -23,6 +23,7 @@ import io.sentry.android.replay.capture.SessionCaptureStrategy
import io.sentry.android.replay.gestures.GestureRecorder
import io.sentry.android.replay.gestures.TouchRecorderCallback
import io.sentry.android.replay.util.MainLooperHandler
+import io.sentry.android.replay.util.appContext
import io.sentry.android.replay.util.sample
import io.sentry.android.replay.util.submitSafely
import io.sentry.cache.PersistingScopeObserver
@@ -51,7 +52,7 @@ public class ReplayIntegration(
// needed for the Java's call site
constructor(context: Context, dateProvider: ICurrentDateProvider) : this(
- context,
+ context.appContext(),
dateProvider,
null,
null,
@@ -67,7 +68,7 @@ public class ReplayIntegration(
replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null,
mainLooperHandler: MainLooperHandler? = null,
gestureRecorderProvider: (() -> GestureRecorder)? = null
- ) : this(context, dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) {
+ ) : this(context.appContext(), dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) {
this.replayCaptureStrategyProvider = replayCaptureStrategyProvider
this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler()
this.gestureRecorderProvider = gestureRecorderProvider
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 fdab9f442d3..4a229f85df6 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
@@ -24,10 +24,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
@@ -115,6 +115,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()
@@ -122,13 +123,13 @@ internal class ScreenshotRecorder(
}
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
- root.traverse(viewHierarchy)
+ 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
@@ -143,7 +144,7 @@ internal class ScreenshotRecorder(
}
is TextViewHierarchyNode -> {
- val textColor = node.layout.dominantTextColor
+ val textColor = node.layout?.dominantTextColor
?: node.dominantColor
?: Color.BLACK
node.layout.getVisibleRects(
@@ -202,6 +203,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/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt
index e3e6605a968..fb5105565b6 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 37061a5b77c..2625399c99a 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/Context.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt
new file mode 100644
index 00000000000..3c5be33115f
--- /dev/null
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt
@@ -0,0 +1,5 @@
+package io.sentry.android.replay.util
+
+import android.content.Context
+
+internal fun Context.appContext() = this.applicationContext ?: this
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 00000000000..56083717221
--- /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 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
+ * 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 mask it.
+ */
+internal fun Painter.isMaskable(): 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 mask text in Compose.
+ *
+ * We heuristically look up for classes that have a [Text] modifier, usually they all have a
+ * `Text` string in their name, e.g. TextStringSimpleElement or TextAnnotatedStringElement. We then
+ * get the color from the modifier, to be able to mask it with the correct color.
+ *
+ * We also look up for classes that have a [Fill] modifier, usually they all have a `Fill` string in
+ * their name, e.g. FillElement. This is necessary to workaround a Compose bug where single-line
+ * text composable without a `fill` modifier still thinks that there's one and wrongly calculates
+ * horizontal position.
+ *
+ * We also add special proguard rules to keep the `Text` class names and their `color` member.
+ */
+internal fun LayoutNode.findTextAttributes(): TextAttributes {
+ 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 00000000000..cd07c6d1701
--- /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 86c75f2e9dc..0a0656de52e 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
*/
@@ -52,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 -> {
@@ -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/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt
index baf521a2e67..211decc098d 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-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 00000000000..888528f769b
--- /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.isMaskable
+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.shouldMask(isImage: Boolean, options: SentryOptions): Boolean {
+ val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy)
+ if (sentryPrivacyModifier == "unmask") {
+ return false
+ }
+
+ if (sentryPrivacyModifier == "mask") {
+ return true
+ }
+
+ val className = getProxyClassName(isImage)
+ if (options.experimental.sessionReplay.unmaskViewClasses.contains(className)) {
+ return false
+ }
+
+ return options.experimental.sessionReplay.maskViewClasses.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 shouldMask = isVisible && node.shouldMask(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,
+ shouldMask = shouldMask,
+ isImportantForContentCapture = true,
+ isVisible = isVisible,
+ visibleRect = visibleRect
+ )
+ }
+ else -> {
+ val painter = node.findPainter()
+ if (painter != null) {
+ val shouldMask = isVisible && node.shouldMask(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,
+ shouldMask = shouldMask && painter.isMaskable(),
+ visibleRect = visibleRect
+ )
+ } else {
+ 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
+ // 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,
+ shouldMask = shouldMask,
+ 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 90b96f134bb..ef05ecb0296 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.isRedactable
+import io.sentry.android.replay.util.AndroidTextLayout
+import io.sentry.android.replay.util.TextLayout
+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
@TargetApi(26)
@@ -23,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,
@@ -39,14 +41,14 @@ 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: Layout? = null,
+ val layout: TextLayout? = null,
val dominantColor: Int? = null,
val paddingLeft: Int = 0,
val paddingTop: Int = 0,
@@ -57,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,
@@ -71,11 +73,25 @@ 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()
+ * 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
@@ -217,25 +233,8 @@ 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"
+ 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
@@ -249,34 +248,34 @@ 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)
+ parent?.setImportantForCaptureToAncestors(true)
return TextViewHierarchyNode(
- layout = view.layout,
+ layout = view.layout?.let { AndroidTextLayout(it) },
dominantColor = view.currentTextColor.toOpaque(),
paddingLeft = view.totalPaddingLeft,
paddingTop = view.totalPaddingTopSafe,
@@ -285,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,
@@ -295,7 +294,7 @@ sealed class ViewHierarchyNode(
}
is ImageView -> {
- parent.setImportantForCaptureToAncestors(true)
+ parent?.setImportantForCaptureToAncestors(true)
return ImageViewHierarchyNode(
x = view.x,
y = view.y,
@@ -306,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
)
}
@@ -320,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/AndroidManifest.xml b/sentry-android-replay/src/test/AndroidManifest.xml
new file mode 100644
index 00000000000..c8f45a53bbf
--- /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 ec545ed1091..9a5b805ad73 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/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt
new file mode 100644
index 00000000000..e5330fa8277
--- /dev/null
+++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.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.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
+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 ComposeMaskingOptionsTest {
+
+ @Before
+ fun setup() {
+ System.setProperty("robolectric.areWindowsMarkedVisible", "true")
+ ComposeMaskingOptionsActivity.textModifierApplier = null
+ ComposeMaskingOptionsActivity.containerModifierApplier = null
+ }
+
+ @Test
+ fun `when maskAllText is set all Text nodes are masked`() {
+ val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
+
+ val options = SentryOptions().apply {
+ experimental.sessionReplay.maskAllText = true
+ }
+
+ val textNodes = activity.get().collectNodesOfType(options)
+ assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title]
+ 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 maskAllText is set to false all Text nodes are unmasked`() {
+ val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
+
+ val options = SentryOptions().apply {
+ experimental.sessionReplay.maskAllText = false
+ }
+
+ val textNodes = activity.get().collectNodesOfType(options)
+ assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title]
+ assertTrue(textNodes.none { it.shouldMask })
+ }
+
+ @Test
+ fun `when maskAllImages is set all Image nodes are masked`() {
+ val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
+
+ val options = SentryOptions().apply {
+ experimental.sessionReplay.maskAllImages = true
+ }
+
+ val imageNodes = activity.get().collectNodesOfType(options)
+ assertEquals(1, imageNodes.size) // [AsyncImage]
+ assertTrue(imageNodes.all { it.shouldMask })
+ }
+
+ @Test
+ 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.maskAllImages = false
+ }
+
+ val imageNodes = activity.get().collectNodesOfType(options)
+ assertEquals(1, imageNodes.size) // [AsyncImage]
+ assertTrue(imageNodes.none { it.shouldMask })
+ }
+
+ @Test
+ 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.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.shouldMask)
+ } else {
+ assertFalse(it.shouldMask)
+ }
+ }
+ }
+
+ @Test
+ 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.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.shouldMask)
+ } else {
+ assertTrue(it.shouldMask)
+ }
+ }
+ }
+
+ @Test
+ 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.maskAllText = true
+ }
+
+ val textNodes = activity.get().collectNodesOfType(options)
+ textNodes.forEach {
+ if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") {
+ assertFalse(it.shouldMask)
+ } else {
+ assertTrue(it.shouldMask)
+ }
+ }
+ }
+
+ @Test
+ 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()
+
+ val allNodes = activity.get().collectNodesOfType(options)
+ val imageNodes = allNodes.filterIsInstance()
+ val textNodes = allNodes.filterIsInstance()
+ val genericNodes = allNodes.filterIsInstance()
+ assertTrue(imageNodes.all { it.shouldMask })
+ assertTrue(textNodes.all { it.shouldMask })
+ assertTrue(genericNodes.none { it.shouldMask })
+ }
+
+ 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 ComposeMaskingOptionsActivity : 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/MaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt
new file mode 100644
index 00000000000..4a40e0a9150
--- /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 8ffffd046da..00000000000
--- 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.Before
-import org.junit.runner.RunWith
-import org.robolectric.Robolectric.buildActivity
-import org.robolectric.annotation.Config
-import kotlin.test.Test
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-
-@RunWith(AndroidJUnit4::class)
-@Config(sdk = [30])
-class RedactionOptionsTest {
-
- @Before
- fun setup() {
- System.setProperty("robolectric.areWindowsMarkedVisible", "true")
- }
-
- @Test
- fun `when redactAllText is set all TextView nodes are redacted`() {
- buildActivity(ExampleActivity::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)
-
- 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(ExampleActivity::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)
-
- 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(ExampleActivity::class.java).setup()
-
- val options = SentryOptions().apply {
- experimental.sessionReplay.redactAllImages = true
- }
-
- val imageNode = ViewHierarchyNode.fromView(ExampleActivity.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(ExampleActivity::class.java).setup()
-
- val options = SentryOptions().apply {
- experimental.sessionReplay.redactAllImages = false
- }
-
- val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options)
-
- assertTrue(imageNode is ImageViewHierarchyNode)
- assertFalse(imageNode.shouldRedact)
- }
-
- @Test
- fun `when sentry-redact tag is set redacts the view`() {
- buildActivity(ExampleActivity::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)
-
- assertTrue(textNode.shouldRedact)
- }
-
- @Test
- fun `when sentry-ignore tag is set ignores the view`() {
- buildActivity(ExampleActivity::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)
-
- assertFalse(textNode.shouldRedact)
- }
-
- @Test
- fun `when sentry-privacy tag is set to redact redacts the view`() {
- buildActivity(ExampleActivity::class.java).setup()
-
- val options = SentryOptions().apply {
- experimental.sessionReplay.redactAllText = false
- }
-
- ExampleActivity.textView!!.sentryReplayRedact()
- val textNode = ViewHierarchyNode.fromView(ExampleActivity.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()
-
- val options = SentryOptions().apply {
- experimental.sessionReplay.redactAllText = true
- }
-
- ExampleActivity.textView!!.sentryReplayIgnore()
- val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options)
-
- assertFalse(textNode.shouldRedact)
- }
-
- @Test
- fun `when view is not visible, does not redact the view`() {
- buildActivity(ExampleActivity::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)
-
- assertFalse(textNode.shouldRedact)
- }
-
- @Test
- fun `when added to redact list redacts custom view`() {
- buildActivity(ExampleActivity::class.java).setup()
-
- val options = SentryOptions().apply {
- experimental.sessionReplay.redactViewClasses.add(CustomView::class.java.canonicalName)
- }
-
- val customViewNode = ViewHierarchyNode.fromView(ExampleActivity.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()
-
- 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)
-
- assertTrue(textNode.shouldRedact)
- assertFalse(radioButtonNode.shouldRedact)
- }
-
- @Test
- fun `when a container view is ignored its children are not ignored`() {
- buildActivity(ExampleActivity::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)
-
- 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 ExampleActivity : 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/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts
index e86dab253d4..0f3cffecc20 100644
--- a/sentry-samples/sentry-samples-android/build.gradle.kts
+++ b/sentry-samples/sentry-samples-android/build.gradle.kts
@@ -125,6 +125,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 a17e11ef119..cc6e99cc605 100644
--- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
+++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
@@ -161,7 +161,6 @@
-
-
+
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 1a4929b0b7e..3d2e670495d 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.sentryReplayUnmask
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.sentryReplayUnmask())
}
}
}
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 00000000000..28a3442987b
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api
index 81ce6b4989a..8a9c296b135 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -95,6 +95,7 @@ public final class io/sentry/BaggageHeader {
public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/JsonUnknown, java/lang/Comparable {
public fun ()V
+ public fun (J)V
public fun (Ljava/lang/String;)V
public fun (Ljava/util/Date;)V
public fun compareTo (Lio/sentry/Breadcrumb;)I
@@ -108,6 +109,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/
public fun getData (Ljava/lang/String;)Ljava/lang/Object;
public fun getLevel ()Lio/sentry/SentryLevel;
public fun getMessage ()Ljava/lang/String;
+ public fun getOrigin ()Ljava/lang/String;
public fun getTimestamp ()Ljava/util/Date;
public fun getType ()Ljava/lang/String;
public fun getUnknown ()Ljava/util/Map;
@@ -126,6 +128,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/
public fun setData (Ljava/lang/String;Ljava/lang/Object;)V
public fun setLevel (Lio/sentry/SentryLevel;)V
public fun setMessage (Ljava/lang/String;)V
+ public fun setOrigin (Ljava/lang/String;)V
public fun setType (Ljava/lang/String;)V
public fun setUnknown (Ljava/util/Map;)V
public static fun transaction (Ljava/lang/String;)Lio/sentry/Breadcrumb;
@@ -147,6 +150,7 @@ public final class io/sentry/Breadcrumb$JsonKeys {
public static final field DATA Ljava/lang/String;
public static final field LEVEL Ljava/lang/String;
public static final field MESSAGE Ljava/lang/String;
+ public static final field ORIGIN Ljava/lang/String;
public static final field TIMESTAMP Ljava/lang/String;
public static final field TYPE Ljava/lang/String;
public fun ()V
@@ -424,7 +428,7 @@ public abstract interface class io/sentry/EventProcessor {
}
public final class io/sentry/ExperimentalOptions {
- public fun ()V
+ public fun (Z)V
public fun getSessionReplay ()Lio/sentry/SentryReplayOptions;
public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V
}
@@ -2670,6 +2674,7 @@ public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSeri
public static final field CheckIn Lio/sentry/SentryItemType;
public static final field ClientReport Lio/sentry/SentryItemType;
public static final field Event Lio/sentry/SentryItemType;
+ public static final field Feedback Lio/sentry/SentryItemType;
public static final field Profile Lio/sentry/SentryItemType;
public static final field ReplayEvent Lio/sentry/SentryItemType;
public static final field ReplayRecording Lio/sentry/SentryItemType;
@@ -2686,6 +2691,12 @@ public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSeri
public static fun values ()[Lio/sentry/SentryItemType;
}
+public final class io/sentry/SentryItemType$Deserializer : io/sentry/JsonDeserializer {
+ public fun ()V
+ public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryItemType;
+ public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object;
+}
+
public final class io/sentry/SentryLevel : java/lang/Enum, io/sentry/JsonSerializable {
public static final field DEBUG Lio/sentry/SentryLevel;
public static final field ERROR Lio/sentry/SentryLevel;
@@ -2930,6 +2941,7 @@ public class io/sentry/SentryOptions {
public fun setExecutorService (Lio/sentry/ISentryExecutorService;)V
public fun setFlushTimeoutMillis (J)V
public fun setForceInit (Z)V
+ public fun setFullyDisplayedReporter (Lio/sentry/FullyDisplayedReporter;)V
public fun setGestureTargetLocators (Ljava/util/List;)V
public fun setIdleTimeout (Ljava/lang/Long;)V
public fun setIgnoredCheckIns (Ljava/util/List;)V
@@ -3117,27 +3129,32 @@ 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 fun ()V
+ public static final field VIDEO_VIEW_CLASS_NAME Ljava/lang/String;
+ public static final field WEB_VIEW_CLASS_NAME Ljava/lang/String;
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 (Z)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
}
@@ -5995,6 +6012,7 @@ public final class io/sentry/util/JsonSerializationUtils {
public final class io/sentry/util/LazyEvaluator {
public fun (Lio/sentry/util/LazyEvaluator$Evaluator;)V
public fun getValue ()Ljava/lang/Object;
+ public fun setValue (Ljava/lang/Object;)V
}
public abstract interface class io/sentry/util/LazyEvaluator$Evaluator {
diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java
index 96e3eb44120..d5096455108 100644
--- a/sentry/src/main/java/io/sentry/Breadcrumb.java
+++ b/sentry/src/main/java/io/sentry/Breadcrumb.java
@@ -20,8 +20,11 @@
/** Series of application events */
public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparable {
- /** A timestamp representing when the breadcrumb occurred. */
- private final @NotNull Date timestamp;
+ /** A timestamp representing when the breadcrumb occurred in milliseconds. */
+ private @Nullable final Long timestampMs;
+
+ /** A timestamp representing when the breadcrumb occurred as java.util.Date. */
+ private @Nullable Date timestamp;
private final @NotNull Long nanos;
@@ -37,6 +40,12 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab
/** Dotted strings that indicate what the crumb is or where it comes from. */
private @Nullable String category;
+ /**
+ * Origin of the breadcrumb that is used to identify source of the breadcrumb. For example hybrid
+ * SDKs can identify native breadcrumbs from JS or Flutter.
+ */
+ private @Nullable String origin;
+
/** The level of the event. */
private @Nullable SentryLevel level;
@@ -48,17 +57,27 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab
*
* @param timestamp the timestamp
*/
+ @SuppressWarnings("JavaUtilDate")
public Breadcrumb(final @NotNull Date timestamp) {
this.nanos = System.nanoTime();
this.timestamp = timestamp;
+ this.timestampMs = null;
+ }
+
+ public Breadcrumb(final long timestamp) {
+ this.nanos = System.nanoTime();
+ this.timestampMs = timestamp;
+ this.timestamp = null;
}
Breadcrumb(final @NotNull Breadcrumb breadcrumb) {
this.nanos = System.nanoTime();
this.timestamp = breadcrumb.timestamp;
+ this.timestampMs = breadcrumb.timestampMs;
this.message = breadcrumb.message;
this.type = breadcrumb.type;
this.category = breadcrumb.category;
+ this.origin = breadcrumb.origin;
final Map dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data);
if (dataClone != null) {
this.data = dataClone;
@@ -83,6 +102,7 @@ public static Breadcrumb fromMap(
String type = null;
@NotNull Map data = new ConcurrentHashMap<>();
String category = null;
+ String origin = null;
SentryLevel level = null;
Map unknown = null;
@@ -121,6 +141,9 @@ public static Breadcrumb fromMap(
case JsonKeys.CATEGORY:
category = (value instanceof String) ? (String) value : null;
break;
+ case JsonKeys.ORIGIN:
+ origin = (value instanceof String) ? (String) value : null;
+ break;
case JsonKeys.LEVEL:
String levelString = (value instanceof String) ? (String) value : null;
if (levelString != null) {
@@ -145,6 +168,7 @@ public static Breadcrumb fromMap(
breadcrumb.type = type;
breadcrumb.data = data;
breadcrumb.category = category;
+ breadcrumb.origin = origin;
breadcrumb.level = level;
breadcrumb.setUnknown(unknown);
@@ -508,7 +532,7 @@ public static Breadcrumb fromMap(
/** Breadcrumb ctor */
public Breadcrumb() {
- this(DateUtils.getCurrentDateTime());
+ this(System.currentTimeMillis());
}
/**
@@ -522,13 +546,20 @@ public Breadcrumb(@Nullable String message) {
}
/**
- * Returns the Breadcrumb's timestamp
+ * Returns the Breadcrumb's timestamp as java.util.Date
*
* @return the timestamp
*/
- @SuppressWarnings({"JdkObsolete", "JavaUtilDate"})
+ @SuppressWarnings("JavaUtilDate")
public @NotNull Date getTimestamp() {
- return (Date) timestamp.clone();
+ if (timestamp != null) {
+ return (Date) timestamp.clone();
+ } else if (timestampMs != null) {
+ // we memoize it here into timestamp to avoid instantiating Calendar again and again
+ timestamp = DateUtils.getDateTime(timestampMs);
+ return timestamp;
+ }
+ throw new IllegalStateException("No timestamp set for breadcrumb");
}
/**
@@ -626,6 +657,24 @@ public void setCategory(@Nullable String category) {
this.category = category;
}
+ /**
+ * Returns the origin
+ *
+ * @return the origin
+ */
+ public @Nullable String getOrigin() {
+ return origin;
+ }
+
+ /**
+ * Sets the origin
+ *
+ * @param origin the origin
+ */
+ public void setOrigin(@Nullable String origin) {
+ this.origin = origin;
+ }
+
/**
* Returns the SentryLevel
*
@@ -650,16 +699,17 @@ public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Breadcrumb that = (Breadcrumb) o;
- return timestamp.getTime() == that.timestamp.getTime()
+ return getTimestamp().getTime() == that.getTimestamp().getTime()
&& Objects.equals(message, that.message)
&& Objects.equals(type, that.type)
&& Objects.equals(category, that.category)
+ && Objects.equals(origin, that.origin)
&& level == that.level;
}
@Override
public int hashCode() {
- return Objects.hash(timestamp, message, type, category, level);
+ return Objects.hash(timestamp, message, type, category, origin, level);
}
// region json
@@ -687,6 +737,7 @@ public static final class JsonKeys {
public static final String TYPE = "type";
public static final String DATA = "data";
public static final String CATEGORY = "category";
+ public static final String ORIGIN = "origin";
public static final String LEVEL = "level";
}
@@ -694,7 +745,7 @@ public static final class JsonKeys {
public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger)
throws IOException {
writer.beginObject();
- writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp);
+ writer.name(JsonKeys.TIMESTAMP).value(logger, getTimestamp());
if (message != null) {
writer.name(JsonKeys.MESSAGE).value(message);
}
@@ -705,6 +756,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger
if (category != null) {
writer.name(JsonKeys.CATEGORY).value(category);
}
+ if (origin != null) {
+ writer.name(JsonKeys.ORIGIN).value(origin);
+ }
if (level != null) {
writer.name(JsonKeys.LEVEL).value(logger, level);
}
@@ -729,6 +783,7 @@ public static final class Deserializer implements JsonDeserializer {
String type = null;
@NotNull Map data = new ConcurrentHashMap<>();
String category = null;
+ String origin = null;
SentryLevel level = null;
Map unknown = null;
@@ -758,6 +813,9 @@ public static final class Deserializer implements JsonDeserializer {
case JsonKeys.CATEGORY:
category = reader.nextStringOrNull();
break;
+ case JsonKeys.ORIGIN:
+ origin = reader.nextStringOrNull();
+ break;
case JsonKeys.LEVEL:
try {
level = new SentryLevel.Deserializer().deserialize(reader, logger);
@@ -779,6 +837,7 @@ public static final class Deserializer implements JsonDeserializer {
breadcrumb.type = type;
breadcrumb.data = data;
breadcrumb.category = category;
+ breadcrumb.origin = origin;
breadcrumb.level = level;
breadcrumb.setUnknown(unknown);
diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java
index f587996bd8c..4a0e7de78d1 100644
--- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java
+++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java
@@ -9,7 +9,11 @@
* Beware that experimental options can change at any time.
*/
public final class ExperimentalOptions {
- private @NotNull SentryReplayOptions sessionReplay = new SentryReplayOptions();
+ private @NotNull SentryReplayOptions sessionReplay;
+
+ public ExperimentalOptions(final boolean empty) {
+ this.sessionReplay = new SentryReplayOptions(empty);
+ }
@NotNull
public SentryReplayOptions getSessionReplay() {
diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java
index b0d9c62e905..85acc0aaddd 100644
--- a/sentry/src/main/java/io/sentry/SentryItemType.java
+++ b/sentry/src/main/java/io/sentry/SentryItemType.java
@@ -20,6 +20,7 @@ public enum SentryItemType implements JsonSerializable {
ReplayRecording("replay_recording"),
ReplayVideo("replay_video"),
CheckIn("check_in"),
+ Feedback("feedback"),
Unknown("__unknown__"); // DataCategory.Unknown
private final String itemType;
@@ -61,7 +62,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger
writer.value(itemType);
}
- static final class Deserializer implements JsonDeserializer {
+ public static final class Deserializer implements JsonDeserializer {
@Override
public @NotNull SentryItemType deserialize(
diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java
index fc47b10d715..48557127424 100644
--- a/sentry/src/main/java/io/sentry/SentryOptions.java
+++ b/sentry/src/main/java/io/sentry/SentryOptions.java
@@ -20,6 +20,7 @@
import io.sentry.transport.NoOpEnvelopeCache;
import io.sentry.transport.NoOpTransportGate;
import io.sentry.util.AutoClosableReentrantLock;
+import io.sentry.util.LazyEvaluator;
import io.sentry.util.Platform;
import io.sentry.util.SampleRateUtils;
import io.sentry.util.StringUtils;
@@ -118,11 +119,13 @@ public class SentryOptions {
/** minimum LogLevel to be used if debug is enabled */
private @NotNull SentryLevel diagnosticLevel = DEFAULT_DIAGNOSTIC_LEVEL;
- /** Envelope reader interface */
- private @NotNull IEnvelopeReader envelopeReader = new EnvelopeReader(new JsonSerializer(this));
-
/** Serializer interface to serialize/deserialize json events */
- private @NotNull ISerializer serializer = new JsonSerializer(this);
+ private final @NotNull LazyEvaluator serializer =
+ new LazyEvaluator<>(() -> new JsonSerializer(this));
+
+ /** Envelope reader interface */
+ private final @NotNull LazyEvaluator envelopeReader =
+ new LazyEvaluator<>(() -> new EnvelopeReader(serializer.getValue()));
/** Max depth when serializing object graphs with reflection. * */
private int maxDepth = 100;
@@ -415,7 +418,8 @@ public class SentryOptions {
/** Date provider to retrieve the current date from. */
@ApiStatus.Internal
- private @NotNull SentryDateProvider dateProvider = new SentryAutoDateProvider();
+ private final @NotNull LazyEvaluator dateProvider =
+ new LazyEvaluator<>(() -> new SentryAutoDateProvider());
private final @NotNull List performanceCollectors = new ArrayList<>();
@@ -427,7 +431,7 @@ public class SentryOptions {
private boolean enableTimeToFullDisplayTracing = false;
/** Screen fully displayed reporter, used for time-to-full-display spans. */
- private final @NotNull FullyDisplayedReporter fullyDisplayedReporter =
+ private @NotNull FullyDisplayedReporter fullyDisplayedReporter =
FullyDisplayedReporter.getInstance();
private @NotNull IConnectionStatusProvider connectionStatusProvider =
@@ -476,7 +480,7 @@ public class SentryOptions {
@ApiStatus.Experimental private @Nullable Cron cron = null;
- private final @NotNull ExperimentalOptions experimental = new ExperimentalOptions();
+ private final @NotNull ExperimentalOptions experimental;
private @NotNull ReplayController replayController = NoOpReplayController.getInstance();
@@ -610,7 +614,7 @@ public void setDiagnosticLevel(@Nullable final SentryLevel diagnosticLevel) {
* @return the serializer
*/
public @NotNull ISerializer getSerializer() {
- return serializer;
+ return serializer.getValue();
}
/**
@@ -619,7 +623,7 @@ public void setDiagnosticLevel(@Nullable final SentryLevel diagnosticLevel) {
* @param serializer the serializer
*/
public void setSerializer(@Nullable ISerializer serializer) {
- this.serializer = serializer != null ? serializer : NoOpSerializer.getInstance();
+ this.serializer.setValue(serializer != null ? serializer : NoOpSerializer.getInstance());
}
/**
@@ -641,12 +645,12 @@ public void setMaxDepth(int maxDepth) {
}
public @NotNull IEnvelopeReader getEnvelopeReader() {
- return envelopeReader;
+ return envelopeReader.getValue();
}
public void setEnvelopeReader(final @Nullable IEnvelopeReader envelopeReader) {
- this.envelopeReader =
- envelopeReader != null ? envelopeReader : NoOpEnvelopeReader.getInstance();
+ this.envelopeReader.setValue(
+ envelopeReader != null ? envelopeReader : NoOpEnvelopeReader.getInstance());
}
/**
@@ -2023,6 +2027,13 @@ public void setEnableTimeToFullDisplayTracing(final boolean enableTimeToFullDisp
return fullyDisplayedReporter;
}
+ @ApiStatus.Internal
+ @TestOnly
+ public void setFullyDisplayedReporter(
+ final @NotNull FullyDisplayedReporter fullyDisplayedReporter) {
+ this.fullyDisplayedReporter = fullyDisplayedReporter;
+ }
+
/**
* Whether OPTIONS requests should be traced.
*
@@ -2159,7 +2170,7 @@ public void setIgnoredSpanOrigins(final @Nullable List ignoredSpanOrigin
/** Returns the current {@link SentryDateProvider} that is used to retrieve the current date. */
@ApiStatus.Internal
public @NotNull SentryDateProvider getDateProvider() {
- return dateProvider;
+ return dateProvider.getValue();
}
/**
@@ -2170,7 +2181,7 @@ public void setIgnoredSpanOrigins(final @Nullable List ignoredSpanOrigin
*/
@ApiStatus.Internal
public void setDateProvider(final @NotNull SentryDateProvider dateProvider) {
- this.dateProvider = dateProvider;
+ this.dateProvider.setValue(dateProvider);
}
/**
@@ -2480,6 +2491,7 @@ public SentryOptions() {
* @param empty if options should be empty.
*/
private SentryOptions(final boolean empty) {
+ experimental = new ExperimentalOptions(empty);
if (!empty) {
setSpanFactory(new DefaultSpanFactory());
// SentryExecutorService should be initialized before any
diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java
index 7656b088a15..0c99085726a 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 */
@@ -52,19 +58,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 +79,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
@@ -96,14 +102,21 @@ public enum SentryReplayQuality {
/** The maximum duration of a full session replay, defaults to 1h. */
private long sessionDuration = 60 * 60 * 1000L;
- public SentryReplayOptions() {
- setRedactAllText(true);
- setRedactAllImages(true);
+ public SentryReplayOptions(final boolean empty) {
+ if (!empty) {
+ 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(
final @Nullable Double sessionSampleRate, final @Nullable Double onErrorSampleRate) {
- this();
+ this(false);
this.sessionSampleRate = sessionSampleRate;
this.onErrorSampleRate = onErrorSampleRate;
}
@@ -147,55 +160,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
diff --git a/sentry/src/main/java/io/sentry/TracesSampler.java b/sentry/src/main/java/io/sentry/TracesSampler.java
index 5138e685776..9b215c96c22 100644
--- a/sentry/src/main/java/io/sentry/TracesSampler.java
+++ b/sentry/src/main/java/io/sentry/TracesSampler.java
@@ -22,6 +22,7 @@ public TracesSampler(final @NotNull SentryOptions options) {
this.random = random;
}
+ @SuppressWarnings("deprecation")
@NotNull
public TracesSamplingDecision sample(final @NotNull SamplingContext samplingContext) {
final TracesSamplingDecision samplingContextSamplingDecision =
diff --git a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java
index dbb6a49c19d..d48cc3108dd 100644
--- a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java
+++ b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java
@@ -10,6 +10,7 @@
import io.sentry.SentryOptions;
import io.sentry.Session;
import io.sentry.clientreport.DiscardReason;
+import io.sentry.util.LazyEvaluator;
import io.sentry.util.Objects;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
@@ -36,8 +37,9 @@ abstract class CacheStrategy {
@SuppressWarnings("CharsetObjectCanBeUsed")
protected static final Charset UTF_8 = Charset.forName("UTF-8");
- protected final @NotNull SentryOptions options;
- protected final @NotNull ISerializer serializer;
+ protected @NotNull SentryOptions options;
+ protected final @NotNull LazyEvaluator serializer =
+ new LazyEvaluator<>(() -> options.getSerializer());
protected final @NotNull File directory;
private final int maxSize;
@@ -48,7 +50,6 @@ abstract class CacheStrategy {
Objects.requireNonNull(directoryPath, "Directory is required.");
this.options = Objects.requireNonNull(options, "SentryOptions is required.");
- this.serializer = options.getSerializer();
this.directory = new File(directoryPath);
this.maxSize = maxSize;
@@ -177,7 +178,7 @@ private void moveInitFlagIfNecessary(
&& currentSession.getSessionId().equals(session.getSessionId())) {
session.setInitAsTrue();
try {
- newSessionItem = SentryEnvelopeItem.fromSession(serializer, session);
+ newSessionItem = SentryEnvelopeItem.fromSession(serializer.getValue(), session);
// remove item from envelope items so we can replace with the new one that has the
// init flag true
itemsIterator.remove();
@@ -216,7 +217,7 @@ private void moveInitFlagIfNecessary(
private @Nullable SentryEnvelope readEnvelope(final @NotNull File file) {
try (final InputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
- return serializer.deserializeEnvelope(inputStream);
+ return serializer.getValue().deserializeEnvelope(inputStream);
} catch (IOException e) {
options.getLogger().log(ERROR, "Failed to deserialize the envelope.", e);
}
@@ -258,7 +259,7 @@ private boolean isSessionType(final @Nullable SentryEnvelopeItem item) {
try (final Reader reader =
new BufferedReader(
new InputStreamReader(new ByteArrayInputStream(item.getData()), UTF_8))) {
- return serializer.deserialize(reader, Session.class);
+ return serializer.getValue().deserialize(reader, Session.class);
} catch (Throwable e) {
options.getLogger().log(ERROR, "Failed to deserialize the session.", e);
}
@@ -268,7 +269,7 @@ private boolean isSessionType(final @Nullable SentryEnvelopeItem item) {
private void saveNewEnvelope(
final @NotNull SentryEnvelope envelope, final @NotNull File file, final long timestamp) {
try (final OutputStream outputStream = new FileOutputStream(file)) {
- serializer.serialize(envelope, outputStream);
+ serializer.getValue().serialize(envelope, outputStream);
// we need to set the same timestamp so the sorting from oldest to newest wont break.
file.setLastModified(timestamp);
} catch (Throwable e) {
diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java
index 3a85fb6eb2b..0255d05a18e 100644
--- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java
+++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java
@@ -119,7 +119,7 @@ public void store(final @NotNull SentryEnvelope envelope, final @NotNull Hint hi
try (final Reader reader =
new BufferedReader(
new InputStreamReader(new FileInputStream(currentSessionFile), UTF_8))) {
- final Session session = serializer.deserialize(reader, Session.class);
+ final Session session = serializer.getValue().deserialize(reader, Session.class);
if (session != null) {
writeSessionToDisk(previousSessionFile, session);
}
@@ -207,7 +207,7 @@ private void tryEndPreviousSession(final @NotNull Hint hint) {
try (final Reader reader =
new BufferedReader(
new InputStreamReader(new FileInputStream(previousSessionFile), UTF_8))) {
- final Session session = serializer.deserialize(reader, Session.class);
+ final Session session = serializer.getValue().deserialize(reader, Session.class);
if (session != null) {
final AbnormalExit abnormalHint = (AbnormalExit) sdkHint;
final @Nullable Long abnormalExitTimestamp = abnormalHint.timestamp();
@@ -266,7 +266,7 @@ private void updateCurrentSession(
try (final Reader reader =
new BufferedReader(
new InputStreamReader(new ByteArrayInputStream(item.getData()), UTF_8))) {
- final Session session = serializer.deserialize(reader, Session.class);
+ final Session session = serializer.getValue().deserialize(reader, Session.class);
if (session == null) {
options
.getLogger()
@@ -307,7 +307,7 @@ private void writeEnvelopeToDisk(
}
try (final OutputStream outputStream = new FileOutputStream(file)) {
- serializer.serialize(envelope, outputStream);
+ serializer.getValue().serialize(envelope, outputStream);
} catch (Throwable e) {
options
.getLogger()
@@ -327,7 +327,7 @@ private void writeSessionToDisk(final @NotNull File file, final @NotNull Session
try (final OutputStream outputStream = new FileOutputStream(file);
final Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) {
- serializer.serialize(session, writer);
+ serializer.getValue().serialize(session, writer);
} catch (Throwable e) {
options
.getLogger()
@@ -393,7 +393,7 @@ public void discard(final @NotNull SentryEnvelope envelope) {
for (final File file : allCachedEnvelopes) {
try (final InputStream is = new BufferedInputStream(new FileInputStream(file))) {
- ret.add(serializer.deserializeEnvelope(is));
+ ret.add(serializer.getValue().deserializeEnvelope(is));
} catch (FileNotFoundException e) {
options
.getLogger()
diff --git a/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java b/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java
index 2e7f0c27a7b..fd1f9a9de99 100644
--- a/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java
+++ b/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java
@@ -1,10 +1,12 @@
package io.sentry.clientreport;
import io.sentry.DataCategory;
+import io.sentry.util.LazyEvaluator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.jetbrains.annotations.ApiStatus;
@@ -14,25 +16,28 @@
@ApiStatus.Internal
final class AtomicClientReportStorage implements IClientReportStorage {
- private final @NotNull Map lostEventCounts;
+ private final @NotNull LazyEvaluator