diff --git a/CHANGELOG.md b/CHANGELOG.md index cbedaf37f73..2835d1525b2 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.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/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 846ed78f3cc..fc66c9d6eea 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-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 615caf55504..8a86fcb2c57 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.ignoreViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.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.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/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index f103957999d..1c08379a49e 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 c1bfeb1e526..3db92ea5d80 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 00000000000..e3e6605a968 --- /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 00000000000..37061a5b77c --- /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 8ef595f1934..48c7eb58138 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 145cefff3d4..90b96f134bb 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.isRedactable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.totalPaddingTopSafe @@ -233,14 +234,46 @@ 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.shouldRedact(options: SentryOptions): Boolean { + 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.ignoreViewClasses)) { + return false + } + + return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactViewClasses) } 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.shouldRedact(options) + when (view) { + is TextView -> { parent.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( layout = view.layout, @@ -252,7 +285,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, @@ -261,7 +294,7 @@ sealed class ViewHierarchyNode( ) } - view is ImageView && options.experimental.sessionReplay.redactAllImages -> { + is ImageView -> { parent.setImportantForCaptureToAncestors(true) return ImageViewHierarchyNode( x = view.x, @@ -273,7 +306,7 @@ sealed class ViewHierarchyNode( parent = parent, isVisible = isVisible, isImportantForContentCapture = true, - shouldRedact = isVisible && view.drawable?.isRedactable() == true, + shouldRedact = shouldRedact && view.drawable?.isRedactable() == true, visibleRect = visibleRect ) } @@ -287,7 +320,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 379be515be2..00000000000 --- 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 00000000000..cc60000bcd3 --- /dev/null +++ b/sentry-android-replay/src/main/res/values/public.xml @@ -0,0 +1,5 @@ + + + + + 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 00000000000..8ffffd046da --- /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.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/api/sentry.api b/sentry/api/sentry.api index ceab2fc3264..e53d175081a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2699,16 +2699,18 @@ 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 addClassToRedact (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 getIgnoreViewClasses ()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 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 0024708048d..7656b088a15 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 { + 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 */ LOW(0.8f, 50_000), @@ -49,30 +52,28 @@ 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. + * 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. * - *

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. + *

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

Default is enabled. + *

Default is empty. */ - private boolean redactAllImages = true; + private Set redactViewClasses = new CopyOnWriteArraySet<>(); /** - * 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. + * 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 redactClasses = new CopyOnWriteArraySet<>(); + private Set ignoreViewClasses = new CopyOnWriteArraySet<>(); /** * Defines the quality of the session replay. The higher the quality, the more accurate the replay @@ -95,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; } @@ -141,28 +146,56 @@ 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) { + if (redactAllText) { + addRedactViewClass(TEXT_VIEW_CLASS_NAME); + ignoreViewClasses.remove(TEXT_VIEW_CLASS_NAME); + } else { + addIgnoreViewClass(TEXT_VIEW_CLASS_NAME); + redactViewClasses.remove(TEXT_VIEW_CLASS_NAME); + } } - public void setRedactAllText(final boolean redactAllText) { - this.redactAllText = redactAllText; + /** + * 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) { + if (redactAllImages) { + addRedactViewClass(IMAGE_VIEW_CLASS_NAME); + ignoreViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + } else { + addIgnoreViewClass(IMAGE_VIEW_CLASS_NAME); + redactViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + } } - public boolean getRedactAllImages() { - return redactAllImages; + @NotNull + public Set getRedactViewClasses() { + return this.redactViewClasses; } - public void setRedactAllImages(final boolean redactAllImages) { - this.redactAllImages = redactAllImages; + public void addRedactViewClass(final @NotNull String className) { + this.redactViewClasses.add(className); } - public Set getRedactClasses() { - return this.redactClasses; + @NotNull + public Set getIgnoreViewClasses() { + return this.ignoreViewClasses; } - public void addClassToRedact(final String className) { - this.redactClasses.add(className); + public void addIgnoreViewClass(final @NotNull String className) { + this.ignoreViewClasses.add(className); } @ApiStatus.Internal