Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions sdk/@launchdarkly/observability-android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@ val LdMaskSemanticsKey = SemanticsPropertyKey<Boolean>("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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MaskMatcher> = 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].
*
Expand Down Expand Up @@ -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<MaskMatcher> = 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<MaskMatcher> = 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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -175,13 +176,15 @@ class ImageCaptureService(

private fun collectMasks(capturingWindowEntries: List<WindowEntry>): MutableList<List<Mask>?> {
return capturingWindowEntries.map {
maskCollector.collectMasks(it.rootView, maskMatchers)
maskCollector.collectMasks(it.rootView, explicitMaskMatchers, globalMaskMatchers)
}.toMutableList()
}

private fun collectMasksFromResults(captureResults: List<CaptureResult?>): MutableList<List<Mask>?> {
return captureResults.map { result ->
result?.windowEntry?.rootView?.let { rv -> maskCollector.collectMasks(rv, maskMatchers) }
result?.windowEntry?.rootView?.let { rv ->
maskCollector.collectMasks(rv, explicitMaskMatchers, globalMaskMatchers)
}
}.toMutableList()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>): Boolean {
if (config.contains(SemanticsProperties.Password)) return true

Expand Down
Loading
Loading