diff --git a/CHANGELOG.md b/CHANGELOG.md index 72431094bc..33deafe6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### Internal +- Add integration to track session replay custom masking ([#5088](https://github.com/getsentry/sentry-java/pull/5088)) - Establish new native exception mechanisms to differentiate events generated by `sentry-native` from `ApplicationExitInfo`. ([#5052](https://github.com/getsentry/sentry-java/pull/5052)) - Set `write` permission for `statuses` in the changelog preview GHA workflow. ([#5053](https://github.com/getsentry/sentry-java/pull/5053)) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index cbcadc0391..dcef27d523 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -85,10 +85,12 @@ internal object ComposeViewHierarchyNode { ): Boolean { val sentryPrivacyModifier = this?.getOrNull(SentryReplayModifiers.SentryPrivacy) if (sentryPrivacyModifier == "unmask") { + SentryReplayOptions.trackCustomMasking() return false } if (sentryPrivacyModifier == "mask") { + SentryReplayOptions.trackCustomMasking() return true } 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 2cde60f4fb..08add79d62 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,6 +8,7 @@ import android.view.ViewParent import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions import io.sentry.android.replay.R import io.sentry.android.replay.util.AndroidTextLayout import io.sentry.android.replay.util.TextLayout @@ -291,6 +292,7 @@ internal sealed class ViewHierarchyNode( (tag as? String)?.lowercase()?.contains(SENTRY_UNMASK_TAG) == true || getTag(R.id.sentry_privacy) == "unmask" ) { + SentryReplayOptions.trackCustomMasking() return false } @@ -298,6 +300,7 @@ internal sealed class ViewHierarchyNode( (tag as? String)?.lowercase()?.contains(SENTRY_MASK_TAG) == true || getTag(R.id.sentry_privacy) == "mask" ) { + SentryReplayOptions.trackCustomMasking() return true } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 367b150c92..c4e112f8b2 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -1,5 +1,7 @@ package io.sentry; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import io.sentry.protocol.SdkVersion; import io.sentry.util.SampleRateUtils; import java.util.ArrayList; @@ -13,9 +15,13 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; public final class SentryReplayOptions { + private static final String CUSTOM_MASKING_INTEGRATION_NAME = "ReplayCustomMasking"; + private static volatile boolean customMaskingTracked = false; + public static final String TEXT_VIEW_CLASS_NAME = "android.widget.TextView"; public static final String IMAGE_VIEW_CLASS_NAME = "android.widget.ImageView"; public static final String WEB_VIEW_CLASS_NAME = "android.webkit.WebView"; @@ -209,8 +215,9 @@ public enum SentryReplayQuality { public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { - setMaskAllText(true); - setMaskAllImages(true); + // Add default mask classes directly without setting usingCustomMasking flag + maskViewClasses.add(TEXT_VIEW_CLASS_NAME); + maskViewClasses.add(IMAGE_VIEW_CLASS_NAME); maskViewClasses.add(WEB_VIEW_CLASS_NAME); maskViewClasses.add(VIDEO_VIEW_CLASS_NAME); maskViewClasses.add(ANDROIDX_MEDIA_VIEW_CLASS_NAME); @@ -275,11 +282,12 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { *

Default is enabled. */ public void setMaskAllText(final boolean maskAllText) { + trackCustomMasking(); if (maskAllText) { - addMaskViewClass(TEXT_VIEW_CLASS_NAME); + maskViewClasses.add(TEXT_VIEW_CLASS_NAME); unmaskViewClasses.remove(TEXT_VIEW_CLASS_NAME); } else { - addUnmaskViewClass(TEXT_VIEW_CLASS_NAME); + unmaskViewClasses.add(TEXT_VIEW_CLASS_NAME); maskViewClasses.remove(TEXT_VIEW_CLASS_NAME); } } @@ -293,11 +301,12 @@ public void setMaskAllText(final boolean maskAllText) { *

Default is enabled. */ public void setMaskAllImages(final boolean maskAllImages) { + trackCustomMasking(); if (maskAllImages) { - addMaskViewClass(IMAGE_VIEW_CLASS_NAME); + maskViewClasses.add(IMAGE_VIEW_CLASS_NAME); unmaskViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } else { - addUnmaskViewClass(IMAGE_VIEW_CLASS_NAME); + unmaskViewClasses.add(IMAGE_VIEW_CLASS_NAME); maskViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } } @@ -308,6 +317,7 @@ public Set getMaskViewClasses() { } public void addMaskViewClass(final @NotNull String className) { + trackCustomMasking(); this.maskViewClasses.add(className); } @@ -317,6 +327,7 @@ public Set getUnmaskViewClasses() { } public void addUnmaskViewClass(final @NotNull String className) { + trackCustomMasking(); this.unmaskViewClasses.add(className); } @@ -351,7 +362,7 @@ public long getSessionDuration() { @ApiStatus.Internal public void setMaskViewContainerClass(@NotNull String containerClass) { - addMaskViewClass(containerClass); + maskViewClasses.add(containerClass); maskViewContainerClass = containerClass; } @@ -370,6 +381,19 @@ public void setUnmaskViewContainerClass(@NotNull String containerClass) { return unmaskViewContainerClass; } + @ApiStatus.Internal + public static void trackCustomMasking() { + if (!customMaskingTracked) { + customMaskingTracked = true; + addIntegrationToSdkVersion(CUSTOM_MASKING_INTEGRATION_NAME); + } + } + + @TestOnly + public static void resetCustomMaskingTracked() { + customMaskingTracked = false; + } + @ApiStatus.Internal public boolean isTrackConfiguration() { return trackConfiguration; diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt index cf96bd5d7d..e1391d0b40 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -1,10 +1,19 @@ package io.sentry +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class SentryReplayOptionsTest { + + @BeforeTest + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + SentryReplayOptions.resetCustomMaskingTracked() + } + @Test fun `uses medium quality as default`() { val replayOptions = SentryReplayOptions(true, null) @@ -126,4 +135,80 @@ class SentryReplayOptionsTest { assertTrue(headers.contains("X-Response-Header")) assertTrue(headers.contains("X-Debug-Header")) } + + // Custom Masking Integration Tests + + private fun hasCustomMaskingIntegration(): Boolean { + return SentryIntegrationPackageStorage.getInstance() + .integrations + .contains("ReplayCustomMasking") + } + + @Test + fun `default options does not add ReplayCustomMasking integration`() { + SentryReplayOptions(false, null) + assertFalse(hasCustomMaskingIntegration()) + } + + @Test + fun `empty options does not add ReplayCustomMasking integration`() { + SentryReplayOptions(true, null) + assertFalse(hasCustomMaskingIntegration()) + } + + @Test + fun `addUnmaskViewClass adds ReplayCustomMasking integration`() { + val options = SentryReplayOptions(false, null) + options.addUnmaskViewClass("com.example.MyTextView") + assertTrue(hasCustomMaskingIntegration()) + } + + @Test + fun `setMaskViewContainerClass does not add ReplayCustomMasking integration`() { + val options = SentryReplayOptions(false, null) + options.setMaskViewContainerClass("com.example.MyContainer") + assertFalse(hasCustomMaskingIntegration()) + } + + @Test + fun `setUnmaskViewContainerClass does not add ReplayCustomMasking integration`() { + val options = SentryReplayOptions(false, null) + options.setUnmaskViewContainerClass("com.example.MyContainer") + assertFalse(hasCustomMaskingIntegration()) + } + + @Test + fun `trackCustomMasking adds ReplayCustomMasking integration`() { + SentryReplayOptions.trackCustomMasking() + assertTrue(hasCustomMaskingIntegration()) + } + + @Test + fun `trackCustomMasking only adds integration once`() { + SentryReplayOptions.trackCustomMasking() + SentryReplayOptions.trackCustomMasking() + assertTrue(hasCustomMaskingIntegration()) + assertEquals(1, SentryIntegrationPackageStorage.getInstance().integrations.count { it == "ReplayCustomMasking" }) + } + + @Test + fun `addMaskViewClass adds ReplayCustomMasking integration`() { + val options = SentryReplayOptions(false, null) + options.addMaskViewClass("com.example.MySensitiveView") + assertTrue(hasCustomMaskingIntegration()) + } + + @Test + fun `setMaskAllText adds ReplayCustomMasking integration`() { + val options = SentryReplayOptions(false, null) + options.setMaskAllText(false) + assertTrue(hasCustomMaskingIntegration()) + } + + @Test + fun `setMaskAllImages adds ReplayCustomMasking integration`() { + val options = SentryReplayOptions(false, null) + options.setMaskAllImages(false) + assertTrue(hasCustomMaskingIntegration()) + } }