diff --git a/CHANGELOG.md b/CHANGELOG.md index 3772b92195..7dd2399e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0126) - [diff](https://github.com/getsentry/sentry-native/compare/0.12.4...0.12.6) +### Internal + +- Add integration to track session replay custom masking ([#5070](https://github.com/getsentry/sentry-java/pull/5070)) + ## 8.32.0 ### Features 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..a24a40a294 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") { + options.sessionReplay.trackCustomMasking() return false } if (sentryPrivacyModifier == "mask") { + options.sessionReplay.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..6b66bcef4b 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 @@ -291,6 +291,7 @@ internal sealed class ViewHierarchyNode( (tag as? String)?.lowercase()?.contains(SENTRY_UNMASK_TAG) == true || getTag(R.id.sentry_privacy) == "unmask" ) { + options.sessionReplay.trackCustomMasking() return false } @@ -298,6 +299,7 @@ internal sealed class ViewHierarchyNode( (tag as? String)?.lowercase()?.contains(SENTRY_MASK_TAG) == true || getTag(R.id.sentry_privacy) == "mask" ) { + options.sessionReplay.trackCustomMasking() return true } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4933a32f40..70ff460f3b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3953,6 +3953,7 @@ public final class io/sentry/SentryReplayOptions { public fun setSessionSampleRate (Ljava/lang/Double;)V public fun setTrackConfiguration (Z)V public fun setUnmaskViewContainerClass (Ljava/lang/String;)V + public fun trackCustomMasking ()V } public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum { diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 367b150c92..23fbe2cb07 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; @@ -16,6 +18,9 @@ public final class SentryReplayOptions { + private static final String CUSTOM_MASKING_INTEGRATION_NAME = "ReplayCustomMasking"; + private 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 +214,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); @@ -276,10 +282,11 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { */ public void setMaskAllText(final boolean maskAllText) { 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); + trackCustomMasking(); + unmaskViewClasses.add(TEXT_VIEW_CLASS_NAME); maskViewClasses.remove(TEXT_VIEW_CLASS_NAME); } } @@ -294,10 +301,11 @@ public void setMaskAllText(final boolean maskAllText) { */ public void setMaskAllImages(final boolean maskAllImages) { 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); + trackCustomMasking(); + unmaskViewClasses.add(IMAGE_VIEW_CLASS_NAME); maskViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } } @@ -308,6 +316,7 @@ public Set getMaskViewClasses() { } public void addMaskViewClass(final @NotNull String className) { + trackCustomMasking(); this.maskViewClasses.add(className); } @@ -317,6 +326,7 @@ public Set getUnmaskViewClasses() { } public void addUnmaskViewClass(final @NotNull String className) { + trackCustomMasking(); this.unmaskViewClasses.add(className); } @@ -351,7 +361,7 @@ public long getSessionDuration() { @ApiStatus.Internal public void setMaskViewContainerClass(@NotNull String containerClass) { - addMaskViewClass(containerClass); + maskViewClasses.add(containerClass); maskViewContainerClass = containerClass; } @@ -370,6 +380,14 @@ public void setUnmaskViewContainerClass(@NotNull String containerClass) { return unmaskViewContainerClass; } + @ApiStatus.Internal + public void trackCustomMasking() { + if (!customMaskingTracked) { + customMaskingTracked = true; + addIntegrationToSdkVersion(CUSTOM_MASKING_INTEGRATION_NAME); + } + } + @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..114ef702e4 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -1,10 +1,18 @@ 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() + } + @Test fun `uses medium quality as default`() { val replayOptions = SentryReplayOptions(true, null) @@ -126,4 +134,88 @@ 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 `setMaskAllText true does not set custom integration`() { + val options = SentryReplayOptions(false, null) + options.setMaskAllText(true) + options.setMaskAllImages(true) + assertFalse(hasCustomMaskingIntegration()) + } + + @Test + fun `trackCustomMasking only adds integration once`() { + val options = SentryReplayOptions(false, null) + options.setMaskAllText(false) + options.setMaskAllImages(false) + 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()) + } }