From 2107b0c4bccea922026841d89fff70e510f6adfd Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 5 Sep 2024 13:54:01 +0200 Subject: [PATCH 01/11] Correctly infer text color for RN views --- .../replay/viewhierarchy/ViewHierarchyNode.kt | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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 145cefff3d..b2166bdbe7 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 @@ -3,6 +3,8 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi import android.graphics.Rect import android.text.Layout +import android.text.Spanned +import android.text.style.ForegroundColorSpan import android.view.View import android.widget.ImageView import android.widget.TextView @@ -219,6 +221,30 @@ sealed class ViewHierarchyNode( private fun Int.toOpaque() = this or 0xFF000000.toInt() + private val TextView.dominantTextColor: Int get() { + if (text !is Spqanned) return currentTextColor + + val spans = (text as Spanned).getSpans(0, 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 + } + } + return dominantColor ?: currentTextColor + } + /** * 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: @@ -244,7 +270,7 @@ sealed class ViewHierarchyNode( parent.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( layout = view.layout, - dominantColor = view.currentTextColor.toOpaque(), + dominantColor = view.dominantTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, paddingTop = view.totalPaddingTopSafe, x = view.x, From bee9697fe31e8a2e35d31c4470d8f0100747fb94 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 5 Sep 2024 13:55:18 +0200 Subject: [PATCH 02/11] Fix --- .../io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b2166bdbe7..995176ff53 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 @@ -222,7 +222,7 @@ sealed class ViewHierarchyNode( private fun Int.toOpaque() = this or 0xFF000000.toInt() private val TextView.dominantTextColor: Int get() { - if (text !is Spqanned) return currentTextColor + if (text !is Spanned) return currentTextColor val spans = (text as Spanned).getSpans(0, text.length, ForegroundColorSpan::class.java) From a76e2239f8df8f165dad4ce368b420ffa6663a85 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 9 Sep 2024 11:20:00 +0200 Subject: [PATCH 03/11] Add tests --- .../io/sentry/android/replay/util/Views.kt | 26 ++++++++++ .../replay/viewhierarchy/ViewHierarchyNode.kt | 25 +-------- .../replay/util/TextViewDominantColorTest.kt | 52 +++++++++++++++++++ 3 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt 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 a44508eac6..002b650c38 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 @@ -13,6 +13,8 @@ import android.graphics.drawable.VectorDrawable import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.text.Layout +import android.text.Spanned +import android.text.style.ForegroundColorSpan import android.view.View import android.widget.TextView import java.lang.NullPointerException @@ -101,3 +103,27 @@ internal val TextView.totalPaddingTopSafe: Int } catch (e: NullPointerException) { extendedPaddingTop } + +internal val TextView.dominantTextColor: Int get() { + if (text !is Spanned) return currentTextColor + + val spans = (text as Spanned).getSpans(0, 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 + } + } + return dominantColor ?: currentTextColor +} 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 995176ff53..cc9f0eff73 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -9,6 +9,7 @@ import android.view.View import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions +import io.sentry.android.replay.util.dominantTextColor import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.totalPaddingTopSafe @@ -221,30 +222,6 @@ sealed class ViewHierarchyNode( private fun Int.toOpaque() = this or 0xFF000000.toInt() - private val TextView.dominantTextColor: Int get() { - if (text !is Spanned) return currentTextColor - - val spans = (text as Spanned).getSpans(0, 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 - } - } - return dominantColor ?: currentTextColor - } - /** * 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: 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 new file mode 100644 index 0000000000..a73553baf7 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -0,0 +1,52 @@ +package io.sentry.android.replay.util + +import android.graphics.Color +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class TextViewDominantColorTest { + + @Test + fun `when no spans, returns currentTextColor`() { + val textView = TextView(ApplicationProvider.getApplicationContext()) + textView.text = "Hello, World!" + textView.setTextColor(Color.WHITE) + + assertEquals(Color.WHITE, textView.dominantTextColor) + } + + @Test + fun `when has a foreground color span, returns its color`() { + val textView = TextView(ApplicationProvider.getApplicationContext()) + val text = "Hello, World!" + textView.text = SpannableString(text).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + textView.setTextColor(Color.WHITE) + + assertEquals(Color.RED, textView.dominantTextColor) + } + + @Test + fun `when has multiple foreground color spans, returns color of the longest span`() { + val textView = TextView(ApplicationProvider.getApplicationContext()) + val text = "Hello, World!" + textView.text = SpannableString(text).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, 5, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + setSpan(ForegroundColorSpan(Color.BLACK), 6, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + textView.setTextColor(Color.WHITE) + + assertEquals(Color.BLACK, textView.dominantTextColor) + } +} From 91e86baf43ad578d5c221b1ecc428cc8dbd601b1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 9 Sep 2024 11:20:44 +0200 Subject: [PATCH 04/11] Formatting --- .../io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 2 -- 1 file changed, 2 deletions(-) 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 cc9f0eff73..b5fc67f708 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 @@ -3,8 +3,6 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi import android.graphics.Rect import android.text.Layout -import android.text.Spanned -import android.text.style.ForegroundColorSpan import android.view.View import android.widget.ImageView import android.widget.TextView From c46fcc0972f92a32a007e5b3cf8aa4d6b7588a23 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 9 Sep 2024 11:24:44 +0200 Subject: [PATCH 05/11] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 418c20a938..cbedaf37f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) +- Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) *Breaking changes*: From 6e23fe5b4847560751f883e9a8e52eb482b6b7ff Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 9 Sep 2024 18:38:03 +0200 Subject: [PATCH 06/11] wip --- sentry/src/main/java/io/sentry/ReplayApi.java | 49 +++++++++++++++++++ sentry/src/main/java/io/sentry/Sentry.java | 7 +++ .../java/io/sentry/SentryReplayOptions.java | 30 +++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/ReplayApi.java diff --git a/sentry/src/main/java/io/sentry/ReplayApi.java b/sentry/src/main/java/io/sentry/ReplayApi.java new file mode 100644 index 0000000000..092ef438b6 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayApi.java @@ -0,0 +1,49 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import javax.swing.text.View; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Experimental +public class ReplayApi { + + private final @NotNull ReplayController replayController; + + public ReplayApi(final @NotNull ReplayController replayController) { + this.replayController = replayController; + } + + public void start() { + replayController.start(); + } + + public void stop() { + replayController.stop(); + } + + public void pause() { + replayController.pause(); + } + + public void resume() { + replayController.resume(); + } + + public boolean isRecording() { + return replayController.isRecording(); + } + + @NotNull + public SentryId getReplayId() { + return replayController.getReplayId(); + } + + public void redactView(final @NotNull View view) { + replayController.redactView(view); + } + + public void ignoreView(final @NotNull View view) { + replayController.ignoreView(view); + } +} diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 08571e151a..11bc19a332 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -985,6 +985,13 @@ public static MetricsApi metrics() { return getCurrentHub().metrics(); } + /** the replay API */ + @NotNull + @ApiStatus.Experimental + public static ReplayController replay() { + return getCurrentHub().getOptions().getReplayController(); + } + /** * Configuration options callback * diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 0024708048..7e9f4686ba 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -68,12 +68,28 @@ public enum SentryReplayQuality { /** * 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. + * name of the view, e.g. android.widget.TextView. The subclasses of the specified classes will be + * redacted 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 redactClasses = new CopyOnWriteArraySet<>(); + /** + * Ignore all views with the specified class names from redaction. 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. + * + *

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 ignoreClasses = new CopyOnWriteArraySet<>(); + /** * Defines the quality of the session replay. The higher the quality, the more accurate the replay * will be, but also more data to transfer and more CPU load, defaults to MEDIUM. @@ -157,14 +173,24 @@ public void setRedactAllImages(final boolean redactAllImages) { this.redactAllImages = redactAllImages; } + @NotNull public Set getRedactClasses() { return this.redactClasses; } - public void addClassToRedact(final String className) { + public void addRedactClass(final @NotNull String className) { this.redactClasses.add(className); } + @NotNull + public Set getIgnoreClasses() { + return this.ignoreClasses; + } + + public void addIgnoreClass(final @NotNull String className) { + this.ignoreClasses.add(className); + } + @ApiStatus.Internal public @NotNull SentryReplayQuality getQuality() { return quality; From 3d5e837b07457748087b9f969f4322c3c60c51f0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 12 Sep 2024 22:45:16 +0200 Subject: [PATCH 07/11] Add custom redaction logic --- .../android/core/ManifestMetadataReader.java | 14 +--- .../api/sentry-android-replay.api | 12 +++ .../io/sentry/android/replay/ReplayCache.kt | 1 + .../android/replay/SessionReplayOptions.kt | 31 ++++++++ .../sentry/android/replay/ViewExtensions.kt | 18 +++++ .../java/io/sentry/android/replay/Windows.kt | 18 ++--- .../replay/viewhierarchy/ViewHierarchyNode.kt | 41 +++++++++-- sentry-android-replay/src/main/res/public.xml | 4 - .../src/main/res/values/public.xml | 5 ++ sentry/api/sentry.api | 6 +- sentry/src/main/java/io/sentry/ReplayApi.java | 49 ------------- sentry/src/main/java/io/sentry/Sentry.java | 7 -- .../java/io/sentry/SentryReplayOptions.java | 73 ++++++++++--------- 13 files changed, 153 insertions(+), 126 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt delete mode 100644 sentry-android-replay/src/main/res/public.xml create mode 100644 sentry-android-replay/src/main/res/values/public.xml delete mode 100644 sentry/src/main/java/io/sentry/ReplayApi.java diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 846ed78f3c..fc66c9d6ee 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -409,22 +409,12 @@ static void applyMetadata( options .getExperimental() .getSessionReplay() - .setRedactAllText( - readBool( - metadata, - logger, - REPLAYS_REDACT_ALL_TEXT, - options.getExperimental().getSessionReplay().getRedactAllText())); + .setRedactAllText(readBool(metadata, logger, REPLAYS_REDACT_ALL_TEXT, true)); options .getExperimental() .getSessionReplay() - .setRedactAllImages( - readBool( - metadata, - logger, - REPLAYS_REDACT_ALL_IMAGES, - options.getExperimental().getSessionReplay().getRedactAllImages())); + .setRedactAllImages(readBool(metadata, logger, REPLAYS_REDACT_ALL_IMAGES, true)); } options diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index f103957999..1c08379a49 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -103,6 +103,18 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; } +public final class io/sentry/android/replay/SessionReplayOptionsKt { + public static final fun getRedactAllImages (Lio/sentry/SentryReplayOptions;)Z + public static final fun getRedactAllText (Lio/sentry/SentryReplayOptions;)Z + public static final fun setRedactAllImages (Lio/sentry/SentryReplayOptions;Z)V + public static final fun setRedactAllText (Lio/sentry/SentryReplayOptions;Z)V +} + +public final class io/sentry/android/replay/ViewExtensionsKt { + public static final fun sentryReplayIgnore (Landroid/view/View;)V + public static final fun sentryReplayRedact (Landroid/view/View;)V +} + public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V public fun onRootViewsChanged (Landroid/view/View;Z)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index c1bfeb1e52..3db92ea5d8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -80,6 +80,7 @@ public class ReplayCache( if (replayCacheDir == null || bitmap.isRecycled) { return } + replayCacheDir?.mkdirs() val screenshot = File(replayCacheDir, "$frameTimestamp.jpg").also { it.createNewFile() 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 new file mode 100644 index 0000000000..e3e6605a96 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -0,0 +1,31 @@ +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 +// 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. + * + *

Default is enabled. + */ +var SentryReplayOptions.redactAllText: Boolean + @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) + get() = error("Getter not supported") + set(value) = setRedactAllText(value) + +/** + * Redact 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 + * from the apk. + * + *

Default is enabled. + */ +var SentryReplayOptions.redactAllImages: Boolean + @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) + get() = error("Getter not supported") + set(value) = setRedactAllImages(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 new file mode 100644 index 0000000000..37061a5b77 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt @@ -0,0 +1,18 @@ +package io.sentry.android.replay + +import android.view.View + +/** + * Marks this view to be redacted in session replay. + */ +fun View.sentryReplayRedact() { + setTag(R.id.sentry_privacy, "redact") +} + +/** + * Marks this view to be ignored from redaction in session. + * All its content will be visible in the replay, use with caution. + */ +fun View.sentryReplayIgnore() { + setTag(R.id.sentry_privacy, "ignore") +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 8ef595f193..48c7eb5813 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -134,7 +134,7 @@ internal fun interface OnRootViewsChangedListener { /** * A utility that holds the list of root views that WindowManager updates. */ -internal class RootViewsSpy private constructor() { +internal object RootViewsSpy { val listeners: CopyOnWriteArrayList = object : CopyOnWriteArrayList() { override fun add(element: OnRootViewsChangedListener?): Boolean { @@ -168,15 +168,13 @@ internal class RootViewsSpy private constructor() { } } - companion object { - fun install(): RootViewsSpy { - return RootViewsSpy().apply { - // had to do this as a first message of the main thread queue, otherwise if this is - // called from ContentProvider, it might be too early and the listener won't be installed - Handler(Looper.getMainLooper()).postAtFrontOfQueue { - WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> - delegatingViewList.apply { addAll(mViews) } - } + fun install(): RootViewsSpy { + return apply { + // had to do this as a first message of the main thread queue, otherwise if this is + // called from ContentProvider, it might be too early and the listener won't be installed + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> + delegatingViewList.apply { addAll(mViews) } } } } 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 b5fc67f708..08c5debeb2 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 @@ -7,6 +7,7 @@ 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.dominantTextColor import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser @@ -234,14 +235,38 @@ sealed class ViewHierarchyNode( } } - private fun shouldRedact(view: View, options: SentryOptions): Boolean { - return options.experimental.sessionReplay.redactClasses.contains(view.javaClass.canonicalName) + private const val SENTRY_IGNORE_TAG = "sentry-ignore" + private const val SENTRY_REDACT_TAG = "sentry-redact" + + private fun Class<*>.isAssignableFrom(set: Set): Boolean { + var cls: Class<*>? = this + while (cls != null) { + val canonicalName = cls.canonicalName + if (canonicalName != null && set.contains(canonicalName)) { + return true + } + cls = cls.superclass + } + return false + } + + private fun View.shouldIgnore(options: SentryOptions): Boolean { + return (tag as? String)?.lowercase()?.contains(SENTRY_IGNORE_TAG) == true || + getTag(R.id.sentry_privacy) == "ignore" || + this.javaClass.isAssignableFrom(options.experimental.sessionReplay.ignoreClasses) + } + + private fun View.shouldRedact(options: SentryOptions): Boolean { + return (tag as? String)?.lowercase()?.contains(SENTRY_REDACT_TAG) == true || + getTag(R.id.sentry_privacy) == "redact" || + this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactClasses) } fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { val (isVisible, visibleRect) = view.isVisibleToUser() - when { - view is TextView && options.experimental.sessionReplay.redactAllText -> { + val shouldRedact = isVisible && !view.shouldIgnore(options) && view.shouldRedact(options) + when (view) { + is TextView -> { parent.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( layout = view.layout, @@ -253,7 +278,7 @@ sealed class ViewHierarchyNode( width = view.width, height = view.height, elevation = (parent?.elevation ?: 0f) + view.elevation, - shouldRedact = isVisible, + shouldRedact = shouldRedact, distance = distance, parent = parent, isImportantForContentCapture = true, @@ -262,7 +287,7 @@ sealed class ViewHierarchyNode( ) } - view is ImageView && options.experimental.sessionReplay.redactAllImages -> { + is ImageView -> { parent.setImportantForCaptureToAncestors(true) return ImageViewHierarchyNode( x = view.x, @@ -274,7 +299,7 @@ sealed class ViewHierarchyNode( parent = parent, isVisible = isVisible, isImportantForContentCapture = true, - shouldRedact = isVisible && view.drawable?.isRedactable() == true, + shouldRedact = shouldRedact && view.drawable?.isRedactable() == true, visibleRect = visibleRect ) } @@ -288,7 +313,7 @@ sealed class ViewHierarchyNode( (parent?.elevation ?: 0f) + view.elevation, distance = distance, parent = parent, - shouldRedact = isVisible && shouldRedact(view, options), + shouldRedact = shouldRedact, isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect diff --git a/sentry-android-replay/src/main/res/public.xml b/sentry-android-replay/src/main/res/public.xml deleted file mode 100644 index 379be515be..0000000000 --- a/sentry-android-replay/src/main/res/public.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/sentry-android-replay/src/main/res/values/public.xml b/sentry-android-replay/src/main/res/values/public.xml new file mode 100644 index 0000000000..cc60000bcd --- /dev/null +++ b/sentry-android-replay/src/main/res/values/public.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ceab2fc326..796112e0c8 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2701,13 +2701,13 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent public final class io/sentry/SentryReplayOptions { public fun ()V public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun addClassToRedact (Ljava/lang/String;)V + public fun addIgnoreClass (Ljava/lang/String;)V + public fun addRedactClass (Ljava/lang/String;)V public fun getErrorReplayDuration ()J public fun getFrameRate ()I + public fun getIgnoreClasses ()Ljava/util/Set; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; - public fun getRedactAllImages ()Z - public fun getRedactAllText ()Z public fun getRedactClasses ()Ljava/util/Set; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; diff --git a/sentry/src/main/java/io/sentry/ReplayApi.java b/sentry/src/main/java/io/sentry/ReplayApi.java deleted file mode 100644 index 092ef438b6..0000000000 --- a/sentry/src/main/java/io/sentry/ReplayApi.java +++ /dev/null @@ -1,49 +0,0 @@ -package io.sentry; - -import io.sentry.protocol.SentryId; -import javax.swing.text.View; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -@ApiStatus.Experimental -public class ReplayApi { - - private final @NotNull ReplayController replayController; - - public ReplayApi(final @NotNull ReplayController replayController) { - this.replayController = replayController; - } - - public void start() { - replayController.start(); - } - - public void stop() { - replayController.stop(); - } - - public void pause() { - replayController.pause(); - } - - public void resume() { - replayController.resume(); - } - - public boolean isRecording() { - return replayController.isRecording(); - } - - @NotNull - public SentryId getReplayId() { - return replayController.getReplayId(); - } - - public void redactView(final @NotNull View view) { - replayController.redactView(view); - } - - public void ignoreView(final @NotNull View view) { - replayController.ignoreView(view); - } -} diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 11bc19a332..08571e151a 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -985,13 +985,6 @@ public static MetricsApi metrics() { return getCurrentHub().metrics(); } - /** the replay API */ - @NotNull - @ApiStatus.Experimental - public static ReplayController replay() { - return getCurrentHub().getOptions().getReplayController(); - } - /** * Configuration options callback * diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 7e9f4686ba..83736b3b1c 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -9,6 +9,9 @@ public final class SentryReplayOptions { + private static final String TEXT_VIEW_CLASS_NAME = "android.widget.TextView"; + private static final String IMAGE_VIEW_CLASS_NAME = "android.widget.ImageView"; + public enum SentryReplayQuality { /** Video Scale: 80% Bit Rate: 50.000 */ LOW(0.8f, 50_000), @@ -48,31 +51,13 @@ public enum SentryReplayQuality { */ private @Nullable Double onErrorSampleRate; - /** - * Redact all text content. Draws a rectangle of text bounds with text color on top. By default - * only views extending TextView are redacted. - * - *

Default is enabled. - */ - private boolean redactAllText = true; - - /** - * Redact 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 - * from the apk. - * - *

Default is enabled. - */ - private boolean redactAllImages = true; - /** * 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. * - *

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

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

Default is empty. */ @@ -83,8 +68,8 @@ public enum SentryReplayQuality { * qualified class name of the view, e.g. android.widget.TextView. The subclasses of the specified * classes will be ignored as well. * - *

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

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

Default is empty. */ @@ -111,10 +96,14 @@ public enum SentryReplayQuality { /** The maximum duration of a full session replay, defaults to 1h. */ private long sessionDuration = 60 * 60 * 1000L; - public SentryReplayOptions() {} + public SentryReplayOptions() { + setRedactAllText(true); + setRedactAllImages(true); + } public SentryReplayOptions( final @Nullable Double sessionSampleRate, final @Nullable Double onErrorSampleRate) { + this(); this.sessionSampleRate = sessionSampleRate; this.onErrorSampleRate = onErrorSampleRate; } @@ -157,20 +146,38 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { this.sessionSampleRate = sessionSampleRate; } - public boolean getRedactAllText() { - return redactAllText; - } - + /** + * Redact all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are redacted. + * + *

Default is enabled. + */ public void setRedactAllText(final boolean redactAllText) { - this.redactAllText = redactAllText; - } - - public boolean getRedactAllImages() { - return redactAllImages; + if (redactAllText) { + addRedactClass(TEXT_VIEW_CLASS_NAME); + ignoreClasses.remove(TEXT_VIEW_CLASS_NAME); + } else { + addIgnoreClass(TEXT_VIEW_CLASS_NAME); + redactClasses.remove(TEXT_VIEW_CLASS_NAME); + } } + /** + * Redact 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 + * from the apk. + * + *

Default is enabled. + */ public void setRedactAllImages(final boolean redactAllImages) { - this.redactAllImages = redactAllImages; + if (redactAllImages) { + addRedactClass(IMAGE_VIEW_CLASS_NAME); + ignoreClasses.remove(IMAGE_VIEW_CLASS_NAME); + } else { + addIgnoreClass(IMAGE_VIEW_CLASS_NAME); + redactClasses.remove(IMAGE_VIEW_CLASS_NAME); + } } @NotNull From fe15f79b2a89e44dbbd18edd042bea5558832bb3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 13 Sep 2024 10:42:01 +0200 Subject: [PATCH 08/11] revert --- .../io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 08c5debeb2..6f29dd1bce 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 @@ -270,7 +270,7 @@ sealed class ViewHierarchyNode( parent.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( layout = view.layout, - dominantColor = view.dominantTextColor.toOpaque(), + dominantColor = view.currentTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, paddingTop = view.totalPaddingTopSafe, x = view.x, From a660793e002026f00a3db339d8fe654bc1e76eb4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 13 Sep 2024 16:42:10 +0200 Subject: [PATCH 09/11] Add tests --- .../core/ManifestMetadataReaderTest.kt | 9 +- .../replay/viewhierarchy/ViewHierarchyNode.kt | 29 +- .../viewhierarchy/RedactionOptionsTest.kt | 278 ++++++++++++++++++ sentry/api/sentry.api | 2 + .../java/io/sentry/SentryReplayOptions.java | 4 +- 5 files changed, 305 insertions(+), 17 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 615caf5550..8cbfefe083 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -6,6 +6,7 @@ import androidx.core.os.bundleOf import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger import io.sentry.SentryLevel +import io.sentry.SentryReplayOptions import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -1473,8 +1474,8 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertFalse(fixture.options.experimental.sessionReplay.redactAllImages) - assertFalse(fixture.options.experimental.sessionReplay.redactAllText) + assertTrue(fixture.options.experimental.sessionReplay.ignoreClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.ignoreClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test @@ -1486,7 +1487,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.redactAllImages) - assertTrue(fixture.options.experimental.sessionReplay.redactAllText) + assertTrue(fixture.options.experimental.sessionReplay.redactClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.redactClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } } 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 6f29dd1bce..487347e1d0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -8,7 +8,6 @@ import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions import io.sentry.android.replay.R -import io.sentry.android.replay.util.dominantTextColor import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.totalPaddingTopSafe @@ -250,21 +249,29 @@ sealed class ViewHierarchyNode( return false } - private fun View.shouldIgnore(options: SentryOptions): Boolean { - return (tag as? String)?.lowercase()?.contains(SENTRY_IGNORE_TAG) == true || - getTag(R.id.sentry_privacy) == "ignore" || - this.javaClass.isAssignableFrom(options.experimental.sessionReplay.ignoreClasses) - } - private fun View.shouldRedact(options: SentryOptions): Boolean { - return (tag as? String)?.lowercase()?.contains(SENTRY_REDACT_TAG) == true || - getTag(R.id.sentry_privacy) == "redact" || - this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactClasses) + if ((tag as? String)?.lowercase()?.contains(SENTRY_IGNORE_TAG) == true || + getTag(R.id.sentry_privacy) == "ignore" + ) { + return false + } + + if ((tag as? String)?.lowercase()?.contains(SENTRY_REDACT_TAG) == true || + getTag(R.id.sentry_privacy) == "redact" + ) { + return true + } + + if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.ignoreClasses)) { + return false + } + + return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactClasses) } fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { val (isVisible, visibleRect) = view.isVisibleToUser() - val shouldRedact = isVisible && !view.shouldIgnore(options) && view.shouldRedact(options) + val shouldRedact = isVisible && view.shouldRedact(options) when (view) { is TextView -> { parent.setImportantForCaptureToAncestors(true) 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 new file mode 100644 index 0000000000..a3392678c2 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.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.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.redactClasses.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.ignoreClasses.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.ignoreClasses.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/api/sentry.api b/sentry/api/sentry.api index 796112e0c8..49bce0c8f6 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2699,6 +2699,8 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent } public final class io/sentry/SentryReplayOptions { + 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 fun (Ljava/lang/Double;Ljava/lang/Double;)V public fun addIgnoreClass (Ljava/lang/String;)V diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 83736b3b1c..ddae64c214 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -9,8 +9,8 @@ public final class SentryReplayOptions { - private static final String TEXT_VIEW_CLASS_NAME = "android.widget.TextView"; - private static final String IMAGE_VIEW_CLASS_NAME = "android.widget.ImageView"; + public static final String TEXT_VIEW_CLASS_NAME = "android.widget.TextView"; + public static final String IMAGE_VIEW_CLASS_NAME = "android.widget.ImageView"; public enum SentryReplayQuality { /** Video Scale: 80% Bit Rate: 50.000 */ From e4ec64053e3b916fb2ddf62af58db9721a761b32 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 13 Sep 2024 18:43:20 +0200 Subject: [PATCH 10/11] Changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbedaf37f7..c06e905b45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ - Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) - Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) +- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) + - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags + - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code + - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions + - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactClass()` or `options.experimental.sessionReplay.addIgnoreClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well + - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactClass("android.widget.TextView")` + - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified *Breaking changes*: From f434c5655a3ffff7e77ed6a4e747b7fde4e05190 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Sep 2024 17:52:05 +0200 Subject: [PATCH 11/11] redactClasses -> redactViewClasses --- CHANGELOG.md | 4 +-- .../core/ManifestMetadataReaderTest.kt | 8 ++--- .../replay/viewhierarchy/ViewHierarchyNode.kt | 4 +-- .../viewhierarchy/RedactionOptionsTest.kt | 6 ++-- sentry/api/sentry.api | 8 ++--- .../java/io/sentry/SentryReplayOptions.java | 36 +++++++++---------- 6 files changed, 33 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c06e905b45..2835d1525b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions - - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactClass()` or `options.experimental.sessionReplay.addIgnoreClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well - - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactClass("android.widget.TextView")` + - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well + - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified *Breaking changes*: diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 8cbfefe083..8a86fcb2c5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1474,8 +1474,8 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.ignoreClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.ignoreClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test @@ -1487,7 +1487,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.redactClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.redactClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } } 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 487347e1d0..90b96f134b 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 @@ -262,11 +262,11 @@ sealed class ViewHierarchyNode( return true } - if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.ignoreClasses)) { + if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.ignoreViewClasses)) { return false } - return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactClasses) + return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactViewClasses) } fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt index a3392678c2..8ffffd046d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt @@ -176,7 +176,7 @@ class RedactionOptionsTest { buildActivity(ExampleActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactClasses.add(CustomView::class.java.canonicalName) + experimental.sessionReplay.redactViewClasses.add(CustomView::class.java.canonicalName) } val customViewNode = ViewHierarchyNode.fromView(ExampleActivity.customView!!, null, 0, options) @@ -190,7 +190,7 @@ class RedactionOptionsTest { val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true // all TextView subclasses - experimental.sessionReplay.ignoreClasses.add(RadioButton::class.java.canonicalName) + experimental.sessionReplay.ignoreViewClasses.add(RadioButton::class.java.canonicalName) } val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) @@ -205,7 +205,7 @@ class RedactionOptionsTest { buildActivity(ExampleActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.ignoreClasses.add(LinearLayout::class.java.canonicalName) + experimental.sessionReplay.ignoreViewClasses.add(LinearLayout::class.java.canonicalName) } val linearLayoutNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!.parent as LinearLayout, null, 0, options) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 49bce0c8f6..e53d175081 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2703,14 +2703,14 @@ public final class io/sentry/SentryReplayOptions { public static final field TEXT_VIEW_CLASS_NAME Ljava/lang/String; public fun ()V public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun addIgnoreClass (Ljava/lang/String;)V - public fun addRedactClass (Ljava/lang/String;)V + public fun addIgnoreViewClass (Ljava/lang/String;)V + public fun addRedactViewClass (Ljava/lang/String;)V public fun getErrorReplayDuration ()J public fun getFrameRate ()I - public fun getIgnoreClasses ()Ljava/util/Set; + public fun getIgnoreViewClasses ()Ljava/util/Set; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; - public fun getRedactClasses ()Ljava/util/Set; + public fun getRedactViewClasses ()Ljava/util/Set; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index ddae64c214..7656b088a1 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -61,7 +61,7 @@ public enum SentryReplayQuality { * *

Default is empty. */ - private Set redactClasses = new CopyOnWriteArraySet<>(); + private Set redactViewClasses = new CopyOnWriteArraySet<>(); /** * Ignore all views with the specified class names from redaction. The class name is the fully @@ -73,7 +73,7 @@ public enum SentryReplayQuality { * *

Default is empty. */ - private Set ignoreClasses = new CopyOnWriteArraySet<>(); + private Set ignoreViewClasses = new CopyOnWriteArraySet<>(); /** * Defines the quality of the session replay. The higher the quality, the more accurate the replay @@ -154,11 +154,11 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { */ public void setRedactAllText(final boolean redactAllText) { if (redactAllText) { - addRedactClass(TEXT_VIEW_CLASS_NAME); - ignoreClasses.remove(TEXT_VIEW_CLASS_NAME); + addRedactViewClass(TEXT_VIEW_CLASS_NAME); + ignoreViewClasses.remove(TEXT_VIEW_CLASS_NAME); } else { - addIgnoreClass(TEXT_VIEW_CLASS_NAME); - redactClasses.remove(TEXT_VIEW_CLASS_NAME); + addIgnoreViewClass(TEXT_VIEW_CLASS_NAME); + redactViewClasses.remove(TEXT_VIEW_CLASS_NAME); } } @@ -172,30 +172,30 @@ public void setRedactAllText(final boolean redactAllText) { */ public void setRedactAllImages(final boolean redactAllImages) { if (redactAllImages) { - addRedactClass(IMAGE_VIEW_CLASS_NAME); - ignoreClasses.remove(IMAGE_VIEW_CLASS_NAME); + addRedactViewClass(IMAGE_VIEW_CLASS_NAME); + ignoreViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } else { - addIgnoreClass(IMAGE_VIEW_CLASS_NAME); - redactClasses.remove(IMAGE_VIEW_CLASS_NAME); + addIgnoreViewClass(IMAGE_VIEW_CLASS_NAME); + redactViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } } @NotNull - public Set getRedactClasses() { - return this.redactClasses; + public Set getRedactViewClasses() { + return this.redactViewClasses; } - public void addRedactClass(final @NotNull String className) { - this.redactClasses.add(className); + public void addRedactViewClass(final @NotNull String className) { + this.redactViewClasses.add(className); } @NotNull - public Set getIgnoreClasses() { - return this.ignoreClasses; + public Set getIgnoreViewClasses() { + return this.ignoreViewClasses; } - public void addIgnoreClass(final @NotNull String className) { - this.ignoreClasses.add(className); + public void addIgnoreViewClass(final @NotNull String className) { + this.ignoreViewClasses.add(className); } @ApiStatus.Internal