diff --git a/sdk/@launchdarkly/observability-android/README.md b/sdk/@launchdarkly/observability-android/README.md index ad43be094..d3124a813 100644 --- a/sdk/@launchdarkly/observability-android/README.md +++ b/sdk/@launchdarkly/observability-android/README.md @@ -244,6 +244,19 @@ Call `LDReplay.stop()` to pause recording. Use `ldMask()` to mark views that should be masked in session replay. There are helpers for both XML-based Views and Jetpack Compose. +##### How the SDK Determines What to Mask + +When deciding whether a specific view should be masked in a Session Replay, the SDK evaluates rules in a strict order of precedence. It checks these conditions from top to bottom and stops at the first one that applies: + +1. **Explicit Masking (Highest Priority)**: Is the view, or *any* of its parent views, explicitly masked (e.g., using `.ldMask()` or matching `maskXMLViewIds`)? + * **Yes**: The view is **masked**. This overrides all other rules. +2. **Explicit Unmasking**: Is the view, or *any* of its parent views, explicitly unmasked (e.g., using `.ldUnmask()`)? + * **Yes**: The view is **unmasked**. +3. **Global Configuration**: Does your global privacy configuration (like `maskTextInputs`, `maskImages`, etc.) apply to this view? + * **Yes**: The view follows the global configuration. + +*Note: If multiple rules conflict at the same level, masking wins over unmasking.* + ##### Configure masking via `PrivacyProfile` If you want to configure masking globally (instead of calling `ldMask()` on each element), pass a `PrivacyProfile` to `ReplayOptions`: @@ -322,7 +335,7 @@ override fun onCreateView( } ``` -Optional: use `ldUnmask()` to explicitly clear masking on a view you previously masked. +Use `ldUnmask()` to explicitly opt a view out of masking. This overrides global masking rules (e.g. `maskText`) for the view and its descendants — but an explicit `ldMask()` on the view itself or any ancestor still wins. ##### Jetpack Compose @@ -348,7 +361,7 @@ fun CreditCardField() { } ``` -Optional: use `Modifier.ldUnmask()` to explicitly clear masking on a composable you previously masked. +Use `Modifier.ldUnmask()` to explicitly opt a composable out of masking. This overrides global masking rules (e.g. `maskText`) for the composable and its descendants — but an explicit `ldMask()` on the composable itself or any ancestor still wins. Notes: - Masking marks elements so their contents are obscured in recorded sessions. diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ComposeMaskingAPI.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ComposeMaskingAPI.kt index 5fc724be1..87495e510 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ComposeMaskingAPI.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ComposeMaskingAPI.kt @@ -16,14 +16,18 @@ val LdMaskSemanticsKey = SemanticsPropertyKey("ld_mask") var SemanticsPropertyReceiver.ldMask by LdMaskSemanticsKey /** - * Marks this Compose element as sensitive; session replay should mask it. + * Marks this Compose element — and every descendant of it — as sensitive for masking in session + * replay. */ fun Modifier.ldMask(): Modifier = this.semantics { ldMask = true } /** - * Marks this Compose element as not sensitive; session replay should not mask it. + * Marks this Compose element — and every descendant of it — as explicitly *not* sensitive for + * masking in session replay. This overrides global masking rules such as `maskText` and + * `maskTextInputs` for the affected elements. If this element or one of its ancestors is also + * explicitly masked via [ldMask], the explicit mask wins. */ fun Modifier.ldUnmask(): Modifier = this.semantics { ldMask = false diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/MaskingViewAPI.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/MaskingViewAPI.kt index c7d82f2eb..8eed69b3a 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/MaskingViewAPI.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/MaskingViewAPI.kt @@ -3,16 +3,18 @@ import android.view.View import com.launchdarkly.observability.R /** - * Marks this native View as sensitive for masking in session replay. - * Sets a tag so the replay system can detect and apply masking. + * Marks this native View — and every descendant of it — as sensitive for masking in session + * replay. */ fun View.ldMask() { setTag(R.id.ld_mask_tag, true) } /** - * Unmarks this native View as sensitive for masking in session replay. - * Sets a tag so the replay system will not mask this view. + * Marks this native View — and every descendant of it — as explicitly *not* sensitive for + * masking in session replay. This overrides global masking rules such as `maskText` and + * `maskTextInputs` for the affected views. If this view or one of its ancestors is also + * explicitly masked via [ldMask], the explicit mask wins. */ fun View.ldUnmask() { setTag(R.id.ld_mask_tag, false) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/PrivacyProfile.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/PrivacyProfile.kt index 2d07a8384..7f71d2ce2 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/PrivacyProfile.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/PrivacyProfile.kt @@ -49,22 +49,6 @@ data class PrivacyProfile( } }.toSet() - /** - * Converts this [PrivacyProfile] into its equivalent [MaskMatcher] list. - * - * Note: matchers are evaluated with `any { ... }`, so ordering only affects performance - * (earlier matchers can short-circuit later ones). - */ - internal fun asMatchersList(): List = buildList { - // Prefer cheaper checks first; heavier checks should be later. - if (maskTextInputs) add(textInputMatcher) - if (maskText) add(textMatcher) - if (viewClassSet.isNotEmpty()) add(viewsMatcher) - if (maskXMLViewIdSet.isNotEmpty()) add(xmlViewIdsMatcher) - if (maskBySemanticsKeywords) add(sensitiveMatcher) - if (webViewClassNameSet.isNotEmpty()) add(webViewClassHierarchyMatcher) - } - /** * Matches targets whose underlying Android View has an exact class match with [maskViews]. * @@ -140,6 +124,40 @@ data class PrivacyProfile( } } + /** + * Matchers whose match counts as an "explicit" masking signal — equivalent to a call to + * `View.ldMask()` on the matched view. An explicit-mask match propagates to descendants per + * the precedence rules in `MaskCollector`. + * + * Identifier-based matchers belong here (the developer named a specific view to mask). + * Type-based / heuristic matchers belong in [globalMaskMatchers]. + * + * Matchers are evaluated with `any { ... }`, so ordering only affects performance (earlier + * matchers can short-circuit later ones). + */ + internal val explicitMaskMatchers: List = buildList { + if (maskXMLViewIdSet.isNotEmpty()) add(xmlViewIdsMatcher) + } + + /** + * Matchers whose match applies only to the matched view itself: a global match does not + * propagate to descendants and does not override an explicit unmask. + * + * Type-based / heuristic matchers belong here (the developer asked to mask all views *of a + * kind*, not specific ones). Identifier-based matchers belong in [explicitMaskMatchers]. + * + * Matchers are evaluated with `any { ... }`, so ordering only affects performance (earlier + * matchers can short-circuit later ones). + */ + internal val globalMaskMatchers: List = buildList { + // Prefer cheaper checks first; heavier checks should be later. + if (maskTextInputs) add(textInputMatcher) + if (maskText) add(textMatcher) + if (viewClassSet.isNotEmpty()) add(viewsMatcher) + if (maskBySemanticsKeywords) add(sensitiveMatcher) + if (webViewClassNameSet.isNotEmpty()) add(webViewClassHierarchyMatcher) + } + // this list of sensitive keywords is used to detect sensitive content descriptions private val sensitiveKeywords = listOf( "sensitive", diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt index 4017ba5ac..ed08e7bf8 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt @@ -46,7 +46,8 @@ class ImageCaptureService( private val windowInspector = WindowInspector(logger) private val maskCollector = MaskCollector(logger) private val maskApplier = MaskApplier() - private val maskMatchers = options.privacyProfile.asMatchersList() + private val explicitMaskMatchers = options.privacyProfile.explicitMaskMatchers + private val globalMaskMatchers = options.privacyProfile.globalMaskMatchers suspend fun captureRawFrame(): RawFrame? = withContext(DispatcherProviderHolder.current.main) { @@ -175,13 +176,15 @@ class ImageCaptureService( private fun collectMasks(capturingWindowEntries: List): MutableList?> { return capturingWindowEntries.map { - maskCollector.collectMasks(it.rootView, maskMatchers) + maskCollector.collectMasks(it.rootView, explicitMaskMatchers, globalMaskMatchers) }.toMutableList() } private fun collectMasksFromResults(captureResults: List): MutableList?> { return captureResults.map { result -> - result?.windowEntry?.rootView?.let { rv -> maskCollector.collectMasks(rv, maskMatchers) } + result?.windowEntry?.rootView?.let { rv -> + maskCollector.collectMasks(rv, explicitMaskMatchers, globalMaskMatchers) + } }.toMutableList() } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt index c2deabfd7..9f9bf8f19 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt @@ -89,6 +89,10 @@ data class ComposeMaskTarget( return config.getOrNull(LdMaskSemanticsKey) == true } + override fun hasLDUnmask(): Boolean { + return config.getOrNull(LdMaskSemanticsKey) == false + } + override fun isSensitive(sensitiveKeywords: List): Boolean { if (config.contains(SemanticsProperties.Password)) return true diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt index 45816d9dc..32281c5d8 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt @@ -19,24 +19,62 @@ private val abstractComposeViewClass: Class<*>? by lazy { } } +/** + * Per-call constants for a single [MaskCollector.collectMasks] traversal. + * + * @param matrix scratch matrix reused while computing per-view transformations. + * @param rootX x-coordinate of the root view in screen space; used to translate window-relative + * points back into root-relative coordinates. + * @param rootY y-coordinate of the root view in screen space. + * @param explicitMaskMatchers matchers whose match counts as an explicit mask signal that + * propagates to descendants (e.g. `PrivacyProfile.explicitMaskMatchers`). + * @param globalMaskMatchers matchers whose match applies only to the matched view itself; they do + * not propagate to descendants and do not override an explicit unmask. + */ data class MaskContext( val matrix: Matrix, val rootX: Float, val rootY: Float, - val matchers: List + val explicitMaskMatchers: List, + val globalMaskMatchers: List, ) /** * Collects sensitive screen areas that should be masked in session replay. * * This encapsulates both Jetpack Compose and native View detection logic. + * + * # Precedence + * + * For each target the collector evaluates these rules in order, stopping at the first that + * applies: + * + * 1. **Explicit Masking (highest priority).** Is the target — or any of its ancestors — + * explicitly masked (via [MaskTarget.hasLDMask] or matched by any + * [MaskContext.explicitMaskMatchers] entry)? If so, the target is masked. + * 2. **Explicit Unmasking.** Is the target — or any of its ancestors — explicitly unmasked + * (via [MaskTarget.hasLDUnmask])? If so, the target is not masked. + * 3. **Global configuration.** Does any [MaskContext.globalMaskMatchers] entry match the + * target? If so, the target is masked. Global matches do not propagate to descendants. + * + * If multiple rules at the same level conflict (e.g. the same view is both `ldMask`-tagged and + * `ldUnmask`-tagged), mask wins over unmask. */ class MaskCollector(private val logger: ObserveLogger) { /** - * Find sensitive areas from all views in the provided [root] view. + * Find sensitive areas from all views under [root] and return a list of masks describing + * regions that should be redacted in the recorded frame. * - * @return a list of masks that represent sensitive areas that need to be masked + * @param root root view of the window being captured; traversal walks its descendants. + * @param explicitMaskMatchers matchers whose match counts as an explicit mask signal that + * propagates to descendants. Pass an empty list when no identifier-based masking is + * configured. + * @param globalMaskMatchers matchers whose match applies only to the matched view itself. */ - fun collectMasks(root: View, matchers: List): List { + fun collectMasks( + root: View, + explicitMaskMatchers: List, + globalMaskMatchers: List, + ): List { val resultMasks = mutableListOf() val (rootX, rootY) = root.locationOnScreen() @@ -44,60 +82,108 @@ class MaskCollector(private val logger: ObserveLogger) { matrix = Matrix(), rootX = rootX, rootY = rootY, - matchers = matchers + explicitMaskMatchers = explicitMaskMatchers, + globalMaskMatchers = globalMaskMatchers, ) - traverse(root, context, resultMasks) + traverse(root, inherited = null, context, resultMasks) return resultMasks } - private fun traverseCompose(view: View, context: MaskContext, masks: MutableList) { + /** + * Dispatcher for one view in the traversal: hands off to the right traversal variant for + * Compose host views, AndroidComposeView wrappers, or plain native Views. Skips views that + * aren't currently shown. + * + * @param view the view to visit. + * @param inherited the resolved-explicit state passed down from the nearest ancestor — `true` + * for inherited mask, `false` for inherited unmask, `null` for no inherited signal. + * @param context per-call constants and matcher configuration. + * @param masks output list; new mask entries are appended to it. + */ + private fun traverse(view: View, inherited: Boolean?, context: MaskContext, masks: MutableList) { + if (!view.isShown) return + + when { + abstractComposeViewClass?.isInstance(view) == true -> traverseCompose(view, inherited, context, masks) + isAndroidComposeView(view) -> traverseAndroidComposeView(view, inherited, context, masks) + else -> traverseNative(view, inherited, context, masks) + } + } + + /** + * Visits a Compose host view: walks its semantics tree to evaluate compose nodes, then + * recurses into any non-compose child views. The compose host itself produces no masking + * signal of its own, so [inherited] is passed through unchanged. + * + * The parameter type is [View] (not `AbstractComposeView`) so this file's signatures don't + * reference a Compose UI class. That keeps the JVM verifier from trying to load Compose + * symbols when MaskCollector loads in apps that don't pull in Compose UI; the cast inside the + * method body is only reached after the dispatcher in [traverse] has confirmed Compose is on + * the classpath via [abstractComposeViewClass]. + * + * @param view a view that is an instance of `AbstractComposeView`. + * @param inherited see [traverse]. + * @param context see [traverse]. + * @param masks see [traverse]. + */ + private fun traverseCompose(view: View, inherited: Boolean?, context: MaskContext, masks: MutableList) { val composeView = view as androidx.compose.ui.platform.AbstractComposeView val target = ComposeMaskTarget.from(composeView, logger) if (target != null) { - traverseComposeNodes(target, context, masks) + traverseComposeNodes(target, inherited, context, masks) } for (i in 0 until composeView.childCount) { val child = composeView.getChildAt(i) - traverse(child, context, masks) + traverse(child, inherited, context, masks) } } - private fun traverseNative(view: View, context: MaskContext, masks: MutableList) { + /** + * Visits a plain native View: applies the precedence rules to decide whether to emit a mask + * for this view, then recurses into its children passing the resolved-explicit state. + * + * @param view the native view to evaluate. + * @param inherited see [traverse]. + * @param context see [traverse]. + * @param masks see [traverse]. + */ + private fun traverseNative(view: View, inherited: Boolean?, context: MaskContext, masks: MutableList) { val target = NativeMaskTarget(view) - if (shouldMask(target, context.matchers)) { - target.mask(context)?.let { masks += it } + val resolvedExplicit = resolveExplicit(target, inherited, context) + if (shouldMask(target, resolvedExplicit, context)) { + target.mask(context)?.let { masks += it } } if (view !is ViewGroup) return for (i in 0 until view.childCount) { val child = view.getChildAt(i) - traverse(child, context, masks) - } - } - - private fun traverse(view: View, context: MaskContext, masks: MutableList) { - if (!view.isShown) return - - when { - abstractComposeViewClass?.isInstance(view) == true -> traverseCompose(view, context, masks) - isAndroidComposeView(view) -> traverseAndroidComposeView(view, context, masks) - else -> traverseNative(view, context, masks) + traverse(child, resolvedExplicit, context, masks) } } /** - * Check if a native view is sensitive and add its bounds to the list if it is. + * Visits a Compose semantics node and its descendants, applying the same precedence rules + * used for native Views. Compose nodes carry their own explicit masking signal via the + * `LdMaskSemanticsKey` semantics property, exposed through [MaskTarget.hasLDMask] / + * [MaskTarget.hasLDUnmask]. + * + * @param target compose target wrapping the node being visited. + * @param inherited see [traverse]. + * @param context see [traverse]. + * @param masks see [traverse]. */ private fun traverseComposeNodes( target: ComposeMaskTarget, + inherited: Boolean?, context: MaskContext, masks: MutableList ) { - if (shouldMask(target, context.matchers)) { - target.mask(context)?.let { masks += it } + val resolvedExplicit = resolveExplicit(target, inherited, context) + if (shouldMask(target, resolvedExplicit, context)) { + target.mask(context)?.let { masks += it } } for (child in target.rootNode.children) { @@ -107,24 +193,82 @@ class MaskCollector(private val logger: ObserveLogger) { config = child.config, boundsInWindow = child.boundsInWindow ) - traverseComposeNodes(childTarget, context, masks) + traverseComposeNodes(childTarget, resolvedExplicit, context, masks) } } - private fun shouldMask( - target: MaskTarget, - matchers: List - ): Boolean { - return target.hasLDMask() - || matchers.any { matcher -> matcher.isMatch(target) } + /** + * Combines the target's own explicit signal with [inherited] from ancestors and returns the + * resolved-explicit state for this target. The result is what gets propagated to descendants + * — global-matcher matches are deliberately not part of this state. + * + * @param target the target whose explicit state we're resolving. + * @param inherited resolved-explicit state from the nearest ancestor. + * @param context provides the [MaskContext.explicitMaskMatchers] consulted during resolution. + * @return `true` for resolved mask, `false` for resolved unmask, `null` when no explicit + * signal applies. + */ + private fun resolveExplicit(target: MaskTarget, inherited: Boolean?, context: MaskContext): Boolean? { + val ownExplicit = explicitOf(target, context) + return when { + ownExplicit == true || inherited == true -> true + ownExplicit == false || inherited == false -> false + else -> null + } } + /** + * The target's *own* explicit signal, ignoring ancestors. Per-view markers + * ([MaskTarget.hasLDMask] / [MaskTarget.hasLDUnmask]) and any + * [MaskContext.explicitMaskMatchers] entry that matches all count as explicit signals; mask + * wins over unmask if both are present on the same target. + * + * @param target the target to inspect. + * @param context provides [MaskContext.explicitMaskMatchers]. + * @return `true` for explicit mask, `false` for explicit unmask, `null` for no signal. + */ + private fun explicitOf(target: MaskTarget, context: MaskContext): Boolean? = when { + target.hasLDMask() -> true + context.explicitMaskMatchers.any { it.isMatch(target) } -> true + target.hasLDUnmask() -> false + else -> null + } + + /** + * Decides whether to emit a mask for [target] given its [resolvedExplicit] state. Falls + * through to [MaskContext.globalMaskMatchers] only when no explicit signal applies to the + * target or any of its ancestors. + * + * @param target the target being evaluated. + * @param resolvedExplicit value returned by [resolveExplicit]. + * @param context provides [MaskContext.globalMaskMatchers]. + */ + private fun shouldMask(target: MaskTarget, resolvedExplicit: Boolean?, context: MaskContext): Boolean { + return resolvedExplicit ?: context.globalMaskMatchers.any { it.isMatch(target) } + } + + /** + * Whether [view] is the internal `AndroidComposeView` host that wraps a Compose subtree. + * Detected by class-name suffix because the type isn't part of the public Compose API. + * + * @param view the view to inspect. + */ private fun isAndroidComposeView(view: View): Boolean { return view::class.java.name.contains("AndroidComposeView") } + /** + * Visits an `AndroidComposeView`, which holds Compose content but isn't itself a target we + * evaluate. Recurses into its children carrying [inherited] forward. + * + * @param view the AndroidComposeView; recursion only proceeds if it's a [ViewGroup]. + * @param inherited see [traverse]. + * @param context see [traverse]. + * @param masks see [traverse]. + */ private fun traverseAndroidComposeView( view: View, + inherited: Boolean?, context: MaskContext, masks: MutableList ) { @@ -132,7 +276,7 @@ class MaskCollector(private val logger: ObserveLogger) { for (i in 0 until view.childCount) { val child = view.getChildAt(i) - traverse(child, context, masks) + traverse(child, inherited, context, masks) } } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskMatcher.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskMatcher.kt index a2ea3abc1..f4d6534dc 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskMatcher.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskMatcher.kt @@ -12,7 +12,10 @@ sealed interface MaskTarget { fun isText(): Boolean fun isSensitive(sensitiveKeywords: List): Boolean fun mask(context: MaskContext): Mask? + /** Returns true when this target carries an explicit "mask" marker (e.g. `View.ldMask()`). */ fun hasLDMask(): Boolean + /** Returns true when this target carries an explicit "unmask" marker (e.g. `View.ldUnmask()`). */ + fun hasLDUnmask(): Boolean } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/NativeMaskTarget.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/NativeMaskTarget.kt index ed0a81464..470965539 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/NativeMaskTarget.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/NativeMaskTarget.kt @@ -65,7 +65,11 @@ data class NativeMaskTarget( } override fun hasLDMask(): Boolean { - return view.getTag(R.id.ld_mask_tag) as? Boolean ?: false + return view.getTag(R.id.ld_mask_tag) == true + } + + override fun hasLDUnmask(): Boolean { + return view.getTag(R.id.ld_mask_tag) == false } // return 4 points of polygon under transformations diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/PrivacyProfileTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/PrivacyProfileTest.kt index c88092786..863b87cc1 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/PrivacyProfileTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/PrivacyProfileTest.kt @@ -33,23 +33,21 @@ class PrivacyProfileTest { } @Test - fun `maskViews populates viewClassSet and adds viewsMatcher to matchers list`() { + fun `maskViews populates viewClassSet and adds viewsMatcher to global matchers`() { val maskedClass = FakeMaskedView::class.java val profile = PrivacyProfile(maskViews = listOf(view(maskedClass))) - val matchers = profile.asMatchersList() - assertTrue(matchers.contains(profile.viewsMatcher)) + assertTrue(profile.globalMaskMatchers.contains(profile.viewsMatcher)) val viewClassSet = profile.getPrivateSet("viewClassSet") assertTrue(viewClassSet.contains(maskedClass)) } @Test - fun `maskXMLViewIds normalizes @+id and @id prefixes and adds xmlViewIdsMatcher to matchers list`() { + fun `maskXMLViewIds normalizes @+id and @id prefixes and adds xmlViewIdsMatcher to explicit matchers`() { val profile = PrivacyProfile(maskXMLViewIds = listOf("@+id/foo", "@id/baz", "bar")) - val matchers = profile.asMatchersList() - assertTrue(matchers.contains(profile.xmlViewIdsMatcher)) + assertTrue(profile.explicitMaskMatchers.contains(profile.xmlViewIdsMatcher)) val idSet = profile.getPrivateSet("maskXMLViewIdSet") assertTrue(idSet.contains("foo")) @@ -63,8 +61,7 @@ class PrivacyProfileTest { fun `maskImageViews adds ImageView to viewClassSet and includes viewsMatcher even when maskViews is empty`() { val profile = PrivacyProfile(maskImageViews = true, maskViews = emptyList()) - val matchers = profile.asMatchersList() - assertTrue(matchers.contains(profile.viewsMatcher)) + assertTrue(profile.globalMaskMatchers.contains(profile.viewsMatcher)) val viewClassSet = profile.getPrivateSet("viewClassSet") assertTrue(viewClassSet.contains(ImageView::class.java)) @@ -74,8 +71,7 @@ class PrivacyProfileTest { fun `maskImageViews false does not add ImageView to viewClassSet and does not include viewsMatcher when maskViews is empty`() { val profile = PrivacyProfile(maskImageViews = false, maskViews = emptyList()) - val matchers = profile.asMatchersList() - assertFalse(matchers.contains(profile.viewsMatcher)) + assertFalse(profile.globalMaskMatchers.contains(profile.viewsMatcher)) val viewClassSet = profile.getPrivateSet("viewClassSet") assertFalse(viewClassSet.contains(ImageView::class.java)) @@ -85,8 +81,7 @@ class PrivacyProfileTest { fun `maskWebViews adds default WebView class names to class name matcher set`() { val profile = PrivacyProfile(maskWebViews = true) - val matchers = profile.asMatchersList() - assertTrue(matchers.contains(profile.webViewClassHierarchyMatcher)) + assertTrue(profile.globalMaskMatchers.contains(profile.webViewClassHierarchyMatcher)) val classNameSet = profile.getPrivateSet("webViewClassNameSet") assertTrue( diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/masking/MaskCollectorTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/masking/MaskCollectorTest.kt new file mode 100644 index 000000000..9e0ae9ee7 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/masking/MaskCollectorTest.kt @@ -0,0 +1,126 @@ +package com.launchdarkly.observability.replay.masking + +import android.view.View +import android.view.ViewGroup +import com.launchdarkly.observability.context.ObserveLogger +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** Behavioral tests for [MaskCollector]'s precedence rules. */ +class MaskCollectorTest { + private val logger = mockk(relaxed = true) + private val collector = MaskCollector(logger) + + /** Matcher that matches every target. Used to stand in for a "global config matched" signal. */ + private val matchAll = object : MaskMatcher { + override fun isMatch(target: MaskTarget): Boolean = true + } + + /** + * Builds a mocked leaf [View] with a controllable per-view masking signal. width/height are + * positive so [NativeMaskTarget.mask] returns a non-null Mask, and `isShown=true` so + * traversal visits the view. The masking signal is stubbed onto `view.getTag(any())` because + * `NativeMaskTarget.hasLDMask` / `hasLDUnmask` look it up there via `R.id.ld_mask_tag`. + * + * @param ldMaskTag value returned from `view.getTag(R.id.ld_mask_tag)`. `true` = explicit + * mask (`ldMask()`), `false` = explicit unmask (`ldUnmask()`), `null` = no signal. + */ + private fun mockLeaf(ldMaskTag: Boolean? = null): View = mockk(relaxed = true).also { + every { it.isShown } returns true + every { it.getTag(any()) } returns ldMaskTag + every { it.width } returns 10 + every { it.height } returns 10 + every { it.id } returns View.NO_ID + } + + /** + * Builds a mocked [ViewGroup] containing the given [children], plus the same per-view stubs + * as [mockLeaf]. + * + * @param children child views in declaration order; will be returned by `getChildAt(i)`. + * @param ldMaskTag value returned from `view.getTag(R.id.ld_mask_tag)`. See [mockLeaf]. + */ + private fun mockGroup(vararg children: View, ldMaskTag: Boolean? = null): ViewGroup = + mockk(relaxed = true).also { + every { it.isShown } returns true + every { it.getTag(any()) } returns ldMaskTag + every { it.width } returns 10 + every { it.height } returns 10 + every { it.id } returns View.NO_ID + every { it.childCount } returns children.size + children.forEachIndexed { i, c -> every { it.getChildAt(i) } returns c } + } + + @Test + fun `ancestor ldMask propagates to descendant`() { + val child = mockLeaf() + val parent = mockGroup(child, ldMaskTag = true) + + val masks = collector.collectMasks(parent, emptyList(), emptyList()) + + // Parent emits a mask via its own hasLDMask; child emits one via inherited mask. + assertEquals(2, masks.size) + } + + @Test + fun `descendant ldUnmask does not override ancestor ldMask`() { + val child = mockLeaf(ldMaskTag = false) + val parent = mockGroup(child, ldMaskTag = true) + + val masks = collector.collectMasks(parent, emptyList(), emptyList()) + + // Ancestor mask wins; child's ldUnmask tag is ignored when an ancestor is explicitly masked. + assertEquals(2, masks.size) + } + + @Test + fun `ancestor ldUnmask overrides global matcher on descendant`() { + val child = mockLeaf() + val parent = mockGroup(child, ldMaskTag = false) + + val masks = collector.collectMasks(parent, emptyList(), listOf(matchAll)) + + // Inherited unmask suppresses the global match on the descendant; the parent itself is + // also not masked because its own ldUnmask vetoes the global matcher. + assertEquals(0, masks.size) + } + + @Test + fun `explicit mask matcher wins over ldUnmask on the same view`() { + // Pins the "mask wins over unmask at the same level" rule. A real-world instance is a + // view configured in `maskXMLViewIds` that also carries `ldUnmask()` — the explicit-mask + // signal wins. + val view = mockLeaf(ldMaskTag = false) + + val masks = collector.collectMasks(view, listOf(matchAll), emptyList()) + + assertEquals(1, masks.size) + } + + @Test + fun `ldUnmask on one sibling subtree does not affect another`() { + val unmaskedChild = mockLeaf() + val unmaskedSubtree = mockGroup(unmaskedChild, ldMaskTag = false) + val plainChild = mockLeaf() + val plainSubtree = mockGroup(plainChild) + val root = mockGroup(unmaskedSubtree, plainSubtree) + + val masks = collector.collectMasks(root, emptyList(), listOf(matchAll)) + + // root + plainSubtree + plainChild all match the global matcher (3 masks). The + // unmaskedSubtree branch carries an explicit unmask that propagates down to its child, + // suppressing both (0 masks). Total: 3. + assertEquals(3, masks.size) + } + + @Test + fun `view without explicit signal falls through to global matcher`() { + val view = mockLeaf() + + val masks = collector.collectMasks(view, emptyList(), listOf(matchAll)) + + assertEquals(1, masks.size) + } +} diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt index 462207283..82792c9fa 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt @@ -7,6 +7,7 @@ import android.os.Looper import com.facebook.react.bridge.ReadableMap import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.ObservabilityOptions +import com.launchdarkly.observability.context.LDObserveLogging import com.launchdarkly.observability.plugin.Observability import com.launchdarkly.observability.replay.PrivacyProfile import com.launchdarkly.observability.replay.ReplayOptions @@ -140,7 +141,7 @@ internal class SessionReplayClientAdapter private constructor() { mobileKey = mobileKey, options = ObservabilityOptions( serviceName = serviceName, - logAdapter = LDAndroidLogging.adapter(), + logAdapter = LDObserveLogging.adapter(), // Disable the OpenTelemetry Android CrashReporterInstrumentation instrumentations = ObservabilityOptions.Instrumentations( crashReporting = false,