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 d41915ab08c..2f0d363c013 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 @@ -104,6 +104,10 @@ final class ManifestMetadataReader { static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.error-sample-rate"; + static final String REPLAYS_REDACT_ALL_TEXT = "io.sentry.session-replay.redact-all-text"; + + static final String REPLAYS_REDACT_ALL_IMAGES = "io.sentry.session-replay.redact-all-images"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -376,23 +380,40 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); - if (options.getExperimental().getSessionReplayOptions().getSessionSampleRate() == null) { + if (options.getExperimental().getSessionReplay().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); if (sessionSampleRate != -1) { - options - .getExperimental() - .getSessionReplayOptions() - .setSessionSampleRate(sessionSampleRate); + options.getExperimental().getSessionReplay().setSessionSampleRate(sessionSampleRate); } } - if (options.getExperimental().getSessionReplayOptions().getErrorSampleRate() == null) { + if (options.getExperimental().getSessionReplay().getErrorSampleRate() == null) { final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); if (errorSampleRate != -1) { - options.getExperimental().getSessionReplayOptions().setErrorSampleRate(errorSampleRate); + options.getExperimental().getSessionReplay().setErrorSampleRate(errorSampleRate); } } + + options + .getExperimental() + .getSessionReplay() + .setRedactAllText( + readBool( + metadata, + logger, + REPLAYS_REDACT_ALL_TEXT, + options.getExperimental().getSessionReplay().getRedactAllText())); + + options + .getExperimental() + .getSessionReplay() + .setRedactAllImages( + readBool( + metadata, + logger, + REPLAYS_REDACT_ALL_IMAGES, + options.getExperimental().getSessionReplay().getRedactAllImages())); } 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 b46743ef7e9..d57855444fd 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 @@ -1383,14 +1383,14 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) } @Test fun `applyMetadata does not override replays errorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options.experimental.sessionReplayOptions.errorSampleRate = expectedSampleRate.toDouble() + fixture.options.experimental.sessionReplay.errorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1398,7 +1398,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) } @Test @@ -1410,6 +1410,33 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.experimental.sessionReplayOptions.errorSampleRate) + assertNull(fixture.options.experimental.sessionReplay.errorSampleRate) + } + + @Test + fun `applyMetadata reads session replay redact flags to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_REDACT_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_REDACT_ALL_IMAGES to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.experimental.sessionReplay.redactAllImages) + assertFalse(fixture.options.experimental.sessionReplay.redactAllText) + } + + @Test + fun `applyMetadata reads session replay redact flags to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.experimental.sessionReplay.redactAllImages) + assertTrue(fixture.options.experimental.sessionReplay.redactAllText) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index f8f266b1498..17d76a752b3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -341,7 +341,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true - options.experimental.sessionReplayOptions.errorSampleRate = 1.0 + options.experimental.sessionReplay.errorSampleRate = 1.0 } var session: Session? = null diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 32f1891af6c..53e9771e136 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -118,6 +118,6 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { - public final fun fromView (Landroid/view/View;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public final fun fromView (Landroid/view/View;Lio/sentry/SentryOptions;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 94a6ccf1a47..d8df5f830aa 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -70,7 +70,7 @@ class ReplayIntegration( private val recorderConfig by lazy(NONE) { ScreenshotRecorderConfig.from( context, - options.experimental.sessionReplayOptions + options.experimental.sessionReplay ) } @@ -89,16 +89,16 @@ class ReplayIntegration( return } - if (!options.experimental.sessionReplayOptions.isSessionReplayEnabled && - !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + if (!options.experimental.sessionReplay.isSessionReplayEnabled && + !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, no sample rate specified") return } - isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) + isFullSession.set(sample(options.experimental.sessionReplay.sessionSampleRate)) if (!isFullSession.get() && - !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, full session was not sampled and errorSampleRate is not specified") return @@ -182,12 +182,12 @@ class ReplayIntegration( return } - if (!sample(options.experimental.sessionReplayOptions.errorSampleRate)) { + if (!sample(options.experimental.sessionReplay.errorSampleRate)) { options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) return } - val errorReplayDuration = options.experimental.sessionReplayOptions.errorReplayDuration + val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { // in buffer mode we have to set the timestamp of the first frame as the actual start @@ -277,7 +277,7 @@ class ReplayIntegration( val now = dateProvider.currentTimeMillis if (isFullSession.get() && - (now - segmentTimestamp.get().time >= options.experimental.sessionReplayOptions.sessionSegmentDuration) + (now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration) ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() @@ -285,7 +285,7 @@ class ReplayIntegration( val videoDuration = createAndCaptureSegment( - options.experimental.sessionReplayOptions.sessionSegmentDuration, + options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, replayId, segmentId @@ -296,12 +296,12 @@ class ReplayIntegration( segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } } else if (isFullSession.get() && - (now - replayStartTimestamp.get() >= options.experimental.sessionReplayOptions.sessionDuration) + (now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration) ) { stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } else if (!isFullSession.get()) { - cache?.rotate(now - options.experimental.sessionReplayOptions.errorReplayDuration) + cache?.rotate(now - options.experimental.sessionReplay.errorReplayDuration) } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 7cba3bf54ac..aaa7200abb7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -164,7 +164,7 @@ internal class ScreenshotRecorder( return } - val rootNode = ViewHierarchyNode.fromView(root) + val rootNode = ViewHierarchyNode.fromView(root, options) root.traverse(rootNode) pendingViewHierarchy.set(rootNode) @@ -227,7 +227,7 @@ internal class ScreenshotRecorder( for (i in 0 until childCount) { val child = getChildAt(i) if (child != null) { - val childNode = ViewHierarchyNode.fromView(child) + val childNode = ViewHierarchyNode.fromView(child, options) childNodes.add(childNode) child.traverse(childNode) } @@ -260,7 +260,7 @@ public data class ScreenshotRecorderConfig( fun from( context: Context, - sentryReplayOptions: SentryReplayOptions + sessionReplay: SentryReplayOptions ): ScreenshotRecorderConfig { // PixelCopy takes screenshots including system bars, so we have to get the real size here val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager @@ -287,8 +287,8 @@ public data class ScreenshotRecorderConfig( recordingHeight = height, scaleFactorX = width.toFloat() / screenBounds.width(), scaleFactorY = height.toFloat() / screenBounds.height(), - frameRate = sentryReplayOptions.frameRate, - bitRate = sentryReplayOptions.bitRate + frameRate = sessionReplay.frameRate, + bitRate = sessionReplay.bitRate ) } } 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 9b6a068f059..3db97311714 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 @@ -14,6 +14,7 @@ import android.view.View import android.view.accessibility.AccessibilityNodeInfo import android.widget.ImageView import android.widget.TextView +import io.sentry.SentryOptions // TODO: merge with ViewHierarchyNode from sentry-core maybe? @TargetApi(26) @@ -49,14 +50,14 @@ data class ViewHierarchyNode( // TODO: check if this works on RN private fun Int.toOpaque() = this or 0xFF000000.toInt() - fun fromView(view: View): ViewHierarchyNode { + fun fromView(view: View, options: SentryOptions): ViewHierarchyNode { // TODO: Extract redacting into its own class/function // TODO: extract redacting into a separate thread? var shouldRedact = false var dominantColor: Int? = null var rect: Rect? = null - when (view) { - is TextView -> { + when { + view is TextView && options.experimental.sessionReplay.redactAllText -> { // TODO: API level check // TODO: perhaps this is heavy, might reconsider val nodeInfo = if (VERSION.SDK_INT >= VERSION_CODES.R) { @@ -101,7 +102,7 @@ data class ViewHierarchyNode( } } - is ImageView -> { + view is ImageView && options.experimental.sessionReplay.redactAllImages -> { shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) if (shouldRedact) { rect = Rect() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d3200dc76a6..47307d418e3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -310,8 +310,8 @@ public abstract interface class io/sentry/EventProcessor { public final class io/sentry/ExperimentalOptions { public fun ()V - public fun getSessionReplayOptions ()Lio/sentry/SentryReplayOptions; - public fun setSessionReplayOptions (Lio/sentry/SentryReplayOptions;)V + public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; + public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V } public final class io/sentry/ExternalOptions { @@ -2571,12 +2571,16 @@ public final class io/sentry/SentryReplayOptions { public fun getErrorReplayDuration ()J public fun getErrorSampleRate ()Ljava/lang/Double; public fun getFrameRate ()I + public fun getRedactAllImages ()Z + public fun getRedactAllText ()Z public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z public fun setErrorSampleRate (Ljava/lang/Double;)V + public fun setRedactAllImages (Z)V + public fun setRedactAllText (Z)V public fun setSessionSampleRate (Ljava/lang/Double;)V } diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index ebd1adabb2f..f587996bd8c 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -9,14 +9,14 @@ *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { - private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); + private @NotNull SentryReplayOptions sessionReplay = new SentryReplayOptions(); @NotNull - public SentryReplayOptions getSessionReplayOptions() { - return sessionReplayOptions; + public SentryReplayOptions getSessionReplay() { + return sessionReplay; } - public void setSessionReplayOptions(final @NotNull SentryReplayOptions sessionReplayOptions) { - this.sessionReplayOptions = sessionReplayOptions; + public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOptions) { + this.sessionReplay = sessionReplayOptions; } } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index c8dc7df7a2d..fb5848513cb 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -20,6 +20,24 @@ public final class SentryReplayOptions { */ private @Nullable Double errorSampleRate; + /** + * 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; + /** * Defines the quality of the session replay. Higher bit rates have better replay quality, but * also affect the final payload size to transfer, defaults to 20kbps. @@ -87,6 +105,22 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { this.sessionSampleRate = sessionSampleRate; } + public boolean getRedactAllText() { + return redactAllText; + } + + public void setRedactAllText(final boolean redactAllText) { + this.redactAllText = redactAllText; + } + + public boolean getRedactAllImages() { + return redactAllImages; + } + + public void setRedactAllImages(final boolean redactAllImages) { + this.redactAllImages = redactAllImages; + } + @ApiStatus.Internal public int getBitRate() { return bitRate;