Skip to content

Commit 7811b4f

Browse files
committed
feat(android): apply masking precedence rules when collecting masks
1 parent e1189e4 commit 7811b4f

10 files changed

Lines changed: 264 additions & 74 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)