Skip to content

Commit 4299d1e

Browse files
authored
feat(android): update session replay masking precedence rules (#518)
## Summary Changes Android semantics to match iOS when applying session replay masking precedence rules. This changes existing masking behavior, but should be clearer than the previous behavior. Please see the README changes for details. ## How did you test this change? Unit tests added. ## Are there any deployment considerations? N/A <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes core session replay privacy behavior by introducing explicit mask/unmask precedence and propagation, which could affect what gets redacted in recordings. Risk is mitigated by added unit tests but still impacts sensitive-data handling paths. > > **Overview** > Aligns Android session replay masking with a new **explicit precedence model**: *explicit mask* (including `ldMask()` and `maskXMLViewIds`) propagates to descendants and overrides everything, *explicit unmask* (`ldUnmask()`) propagates but cannot override an explicit mask, and *global privacy rules* (e.g. `maskTextInputs`, `maskText`, `maskWebViews`) only apply when no explicit signal exists. > > Implements this by splitting `PrivacyProfile` matchers into `explicitMaskMatchers` vs `globalMaskMatchers`, extending `MaskTarget` with `hasLDUnmask`, updating `MaskCollector` traversal to carry inherited explicit state, and wiring `ImageCaptureService` to pass both matcher sets. Adds focused tests for the new precedence rules and updates docs/comments to match; the React Native adapter also switches Observability logging to `LDObserveLogging.adapter()`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d7e05f1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e1189e4 commit 4299d1e

12 files changed

Lines changed: 389 additions & 75 deletions

File tree

sdk/@launchdarkly/observability-android/README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,19 @@ Call `LDReplay.stop()` to pause recording.
244244

245245
Use `ldMask()` to mark views that should be masked in session replay. There are helpers for both XML-based Views and Jetpack Compose.
246246

247+
##### How the SDK Determines What to Mask
248+
249+
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:
250+
251+
1. **Explicit Masking (Highest Priority)**: Is the view, or *any* of its parent views, explicitly masked (e.g., using `.ldMask()` or matching `maskXMLViewIds`)?
252+
* **Yes**: The view is **masked**. This overrides all other rules.
253+
2. **Explicit Unmasking**: Is the view, or *any* of its parent views, explicitly unmasked (e.g., using `.ldUnmask()`)?
254+
* **Yes**: The view is **unmasked**.
255+
3. **Global Configuration**: Does your global privacy configuration (like `maskTextInputs`, `maskImages`, etc.) apply to this view?
256+
* **Yes**: The view follows the global configuration.
257+
258+
*Note: If multiple rules conflict at the same level, masking wins over unmasking.*
259+
247260
##### Configure masking via `PrivacyProfile`
248261

249262
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(
322335
}
323336
```
324337

325-
Optional: use `ldUnmask()` to explicitly clear masking on a view you previously masked.
338+
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.
326339

327340
##### Jetpack Compose
328341

@@ -348,7 +361,7 @@ fun CreditCardField() {
348361
}
349362
```
350363

351-
Optional: use `Modifier.ldUnmask()` to explicitly clear masking on a composable you previously masked.
364+
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.
352365

353366
Notes:
354367
- Masking marks elements so their contents are obscured in recorded sessions.

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ComposeMaskingAPI.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@ val LdMaskSemanticsKey = SemanticsPropertyKey<Boolean>("ld_mask")
1616
var SemanticsPropertyReceiver.ldMask by LdMaskSemanticsKey
1717

1818
/**
19-
* Marks this Compose element as sensitive; session replay should mask it.
19+
* Marks this Compose element — and every descendant of it — as sensitive for masking in session
20+
* replay.
2021
*/
2122
fun Modifier.ldMask(): Modifier = this.semantics {
2223
ldMask = true
2324
}
2425

2526
/**
26-
* Marks this Compose element as not sensitive; session replay should not mask it.
27+
* Marks this Compose element — and every descendant of it — as explicitly *not* sensitive for
28+
* masking in session replay. This overrides global masking rules such as `maskText` and
29+
* `maskTextInputs` for the affected elements. If this element or one of its ancestors is also
30+
* explicitly masked via [ldMask], the explicit mask wins.
2731
*/
2832
fun Modifier.ldUnmask(): Modifier = this.semantics {
2933
ldMask = false

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/MaskingViewAPI.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@ import android.view.View
33
import com.launchdarkly.observability.R
44

55
/**
6-
* Marks this native View as sensitive for masking in session replay.
7-
* Sets a tag so the replay system can detect and apply masking.
6+
* Marks this native View — and every descendant of it — as sensitive for masking in session
7+
* replay.
88
*/
99
fun View.ldMask() {
1010
setTag(R.id.ld_mask_tag, true)
1111
}
1212

1313
/**
14-
* Unmarks this native View as sensitive for masking in session replay.
15-
* Sets a tag so the replay system will not mask this view.
14+
* Marks this native View — and every descendant of it — as explicitly *not* sensitive for
15+
* masking in session replay. This overrides global masking rules such as `maskText` and
16+
* `maskTextInputs` for the affected views. If this view or one of its ancestors is also
17+
* explicitly masked via [ldMask], the explicit mask wins.
1618
*/
1719
fun View.ldUnmask() {
1820
setTag(R.id.ld_mask_tag, false)

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/PrivacyProfile.kt

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,6 @@ data class PrivacyProfile(
4949
}
5050
}.toSet()
5151

52-
/**
53-
* Converts this [PrivacyProfile] into its equivalent [MaskMatcher] list.
54-
*
55-
* Note: matchers are evaluated with `any { ... }`, so ordering only affects performance
56-
* (earlier matchers can short-circuit later ones).
57-
*/
58-
internal fun asMatchersList(): List<MaskMatcher> = buildList {
59-
// Prefer cheaper checks first; heavier checks should be later.
60-
if (maskTextInputs) add(textInputMatcher)
61-
if (maskText) add(textMatcher)
62-
if (viewClassSet.isNotEmpty()) add(viewsMatcher)
63-
if (maskXMLViewIdSet.isNotEmpty()) add(xmlViewIdsMatcher)
64-
if (maskBySemanticsKeywords) add(sensitiveMatcher)
65-
if (webViewClassNameSet.isNotEmpty()) add(webViewClassHierarchyMatcher)
66-
}
67-
6852
/**
6953
* Matches targets whose underlying Android View has an exact class match with [maskViews].
7054
*
@@ -140,6 +124,40 @@ data class PrivacyProfile(
140124
}
141125
}
142126

127+
/**
128+
* Matchers whose match counts as an "explicit" masking signal — equivalent to a call to
129+
* `View.ldMask()` on the matched view. An explicit-mask match propagates to descendants per
130+
* the precedence rules in `MaskCollector`.
131+
*
132+
* Identifier-based matchers belong here (the developer named a specific view to mask).
133+
* Type-based / heuristic matchers belong in [globalMaskMatchers].
134+
*
135+
* Matchers are evaluated with `any { ... }`, so ordering only affects performance (earlier
136+
* matchers can short-circuit later ones).
137+
*/
138+
internal val explicitMaskMatchers: List<MaskMatcher> = buildList {
139+
if (maskXMLViewIdSet.isNotEmpty()) add(xmlViewIdsMatcher)
140+
}
141+
142+
/**
143+
* Matchers whose match applies only to the matched view itself: a global match does not
144+
* propagate to descendants and does not override an explicit unmask.
145+
*
146+
* Type-based / heuristic matchers belong here (the developer asked to mask all views *of a
147+
* kind*, not specific ones). Identifier-based matchers belong in [explicitMaskMatchers].
148+
*
149+
* Matchers are evaluated with `any { ... }`, so ordering only affects performance (earlier
150+
* matchers can short-circuit later ones).
151+
*/
152+
internal val globalMaskMatchers: List<MaskMatcher> = buildList {
153+
// Prefer cheaper checks first; heavier checks should be later.
154+
if (maskTextInputs) add(textInputMatcher)
155+
if (maskText) add(textMatcher)
156+
if (viewClassSet.isNotEmpty()) add(viewsMatcher)
157+
if (maskBySemanticsKeywords) add(sensitiveMatcher)
158+
if (webViewClassNameSet.isNotEmpty()) add(webViewClassHierarchyMatcher)
159+
}
160+
143161
// this list of sensitive keywords is used to detect sensitive content descriptions
144162
private val sensitiveKeywords = listOf(
145163
"sensitive",

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ class ImageCaptureService(
4646
private val windowInspector = WindowInspector(logger)
4747
private val maskCollector = MaskCollector(logger)
4848
private val maskApplier = MaskApplier()
49-
private val maskMatchers = options.privacyProfile.asMatchersList()
49+
private val explicitMaskMatchers = options.privacyProfile.explicitMaskMatchers
50+
private val globalMaskMatchers = options.privacyProfile.globalMaskMatchers
5051

5152
suspend fun captureRawFrame(): RawFrame? =
5253
withContext(DispatcherProviderHolder.current.main) {
@@ -175,13 +176,15 @@ class ImageCaptureService(
175176

176177
private fun collectMasks(capturingWindowEntries: List<WindowEntry>): MutableList<List<Mask>?> {
177178
return capturingWindowEntries.map {
178-
maskCollector.collectMasks(it.rootView, maskMatchers)
179+
maskCollector.collectMasks(it.rootView, explicitMaskMatchers, globalMaskMatchers)
179180
}.toMutableList()
180181
}
181182

182183
private fun collectMasksFromResults(captureResults: List<CaptureResult?>): MutableList<List<Mask>?> {
183184
return captureResults.map { result ->
184-
result?.windowEntry?.rootView?.let { rv -> maskCollector.collectMasks(rv, maskMatchers) }
185+
result?.windowEntry?.rootView?.let { rv ->
186+
maskCollector.collectMasks(rv, explicitMaskMatchers, globalMaskMatchers)
187+
}
185188
}.toMutableList()
186189
}
187190

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ data class ComposeMaskTarget(
8989
return config.getOrNull(LdMaskSemanticsKey) == true
9090
}
9191

92+
override fun hasLDUnmask(): Boolean {
93+
return config.getOrNull(LdMaskSemanticsKey) == false
94+
}
95+
9296
override fun isSensitive(sensitiveKeywords: List<String>): Boolean {
9397
if (config.contains(SemanticsProperties.Password)) return true
9498

0 commit comments

Comments
 (0)