Skip to content
Merged
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
8 changes: 7 additions & 1 deletion sdk/@launchdarkly/observability-android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ When deciding whether a specific view should be masked in a Session Replay, the

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()`)?
2. **Explicit Unmasking**: Is the view, or *any* of its parent views, explicitly unmasked (e.g., using `.ldUnmask()` or matching `unmaskXMLViewIds`)?
* **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.
Expand Down Expand Up @@ -289,6 +289,12 @@ val sessionReplay = SessionReplay(
"@+id/password",
"credit_card_number",
),
unmaskXMLViewIds = listOf(
// Unmasks views matching these ids. Same id format as maskXMLViewIds. Takes
// precedence over global rules like `maskText`/`maskTextInputs`, but an explicit
// mask on the same view or any of its ancestors still wins.
"@+id/greeting",
),
)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import android.widget.ImageView
import com.launchdarkly.observability.replay.masking.MaskMatcher
import com.launchdarkly.observability.replay.masking.MaskTarget

/**
* Normalizes a list of Android XML view id strings into the form returned by
* `Resources.getResourceEntryName()`. Strips the `@+id/` and `@id/` prefixes; bare names
* (`"foo"`) are passed through unchanged. Returns the result as a Set for O(1) lookup.
*/
private fun List<String>.normalizeXmlIds(): Set<String> = map {
when {
it.startsWith("@+id/") -> it.substring(5)
it.startsWith("@id/") -> it.substring(4)
else -> it
}
}.toSet()

/**
* [PrivacyProfile] controls what UI elements are masked in session replay.
*
Expand All @@ -19,6 +32,9 @@ import com.launchdarkly.observability.replay.masking.MaskTarget
* @param maskViews Additional Views to mask by exact class match (see [viewsMatcher]).
* @param maskXMLViewIds Additional Views to mask by resource entry name (see [xmlViewIdsMatcher]).
* accepts `"@+id/foo"`, `"@id/foo"`, or `"foo"`.
* @param unmaskXMLViewIds Views whose resource entry name appears in this list are explicitly
* *unmasked* (see [unmaskXMLViewIdsMatcher]). Same id format as [maskXMLViewIds]. Takes precedence
* over global masking rules — see `MaskCollector` for the full precedence rules.
* @param maskWebViews Set to true to mask known WebView types and their subclasses
* (e.g., "android.webkit.WebView", "org.mozilla.geckoview.GeckoView", etc).
* @param maskBySemanticsKeywords Set to true to enable masking of "sensitive" targets detected by
Expand All @@ -29,6 +45,7 @@ data class PrivacyProfile(
val maskText: Boolean = false,
val maskViews: List<MaskViewRef> = emptyList(),
val maskXMLViewIds: List<String> = emptyList(),
val unmaskXMLViewIds: List<String> = emptyList(),
// only for XML ImageViews
val maskImageViews: Boolean = false,
val maskWebViews: Boolean = false,
Expand All @@ -41,13 +58,8 @@ data class PrivacyProfile(

private val webViewClassNameSet = if (maskWebViews) webViewClassNames.toSet() else emptySet()

private val maskXMLViewIdSet = maskXMLViewIds.map {
when {
it.startsWith("@+id/") -> it.substring(5)
it.startsWith("@id/") -> it.substring(4)
else -> it
}
}.toSet()
private val maskXMLViewIdSet = maskXMLViewIds.normalizeXmlIds()
private val unmaskXMLViewIdSet = unmaskXMLViewIds.normalizeXmlIds()

/**
* Matches targets whose underlying Android View has an exact class match with [maskViews].
Expand Down Expand Up @@ -76,24 +88,38 @@ data class PrivacyProfile(
}

/**
* Matches targets whose underlying Android View's resource entry name is included in
* [maskXMLViewIds].
* Builds a [MaskMatcher] that matches targets whose underlying Android View has a non-
* [View.NO_ID] id whose resource entry name (per `resources.getResourceEntryName(view.id)`)
* appears in [idSet].
*
* IDs are compared using `resources.getResourceEntryName(view.id)`, so this only applies to
* Views with a non-[View.NO_ID] id that resolves to a resource entry.
* @param idSet set of normalized resource entry names to match against.
*/
internal val xmlViewIdsMatcher: MaskMatcher = object : MaskMatcher {
private fun xmlIdMatcher(idSet: Set<String>): MaskMatcher = object : MaskMatcher {
fun View.idNameOrNull(): String? =
if (id == View.NO_ID) null
else runCatching { resources.getResourceEntryName(id) }.getOrNull()

override fun isMatch(target: MaskTarget): Boolean {
val id = target.view.idNameOrNull() ?: return false

return maskXMLViewIdSet.contains(id)
return idSet.contains(id)
}
}

/**
* Matches targets whose underlying Android View's resource entry name is included in
* [maskXMLViewIds].
*
* IDs are compared using `resources.getResourceEntryName(view.id)`, so this only applies to
* Views with a non-[View.NO_ID] id that resolves to a resource entry.
*/
internal val xmlViewIdsMatcher: MaskMatcher = xmlIdMatcher(maskXMLViewIdSet)

/**
* Matches targets whose underlying Android View's resource entry name is included in
* [unmaskXMLViewIds]. Same id resolution as [xmlViewIdsMatcher].
*/
internal val unmaskXMLViewIdsMatcher: MaskMatcher = xmlIdMatcher(unmaskXMLViewIdSet)

/**
* This matcher will match most text inputs, but there may be special cases where it will
* miss as we can't account for all possible future semantic properties.
Expand Down Expand Up @@ -139,6 +165,19 @@ data class PrivacyProfile(
if (maskXMLViewIdSet.isNotEmpty()) add(xmlViewIdsMatcher)
}

/**
* Matchers whose match counts as an "explicit" unmask signal — equivalent to a call to
* `View.ldUnmask()` on the matched view. An explicit-unmask match propagates to descendants
* per the precedence rules in `MaskCollector`. An ancestor's explicit mask still wins over
* an explicit unmask.
*
* Matchers are evaluated with `any { ... }`, so ordering only affects performance (earlier
* matchers can short-circuit later ones).
*/
internal val explicitUnmaskMatchers: List<MaskMatcher> = buildList {
if (unmaskXMLViewIdSet.isNotEmpty()) add(unmaskXMLViewIdsMatcher)
}

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class ImageCaptureService(
private val maskCollector = MaskCollector(logger)
private val maskApplier = MaskApplier()
private val explicitMaskMatchers = options.privacyProfile.explicitMaskMatchers
private val explicitUnmaskMatchers = options.privacyProfile.explicitUnmaskMatchers
private val globalMaskMatchers = options.privacyProfile.globalMaskMatchers

suspend fun captureRawFrame(): RawFrame? =
Expand Down Expand Up @@ -176,14 +177,24 @@ class ImageCaptureService(

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

private fun collectMasksFromResults(captureResults: List<CaptureResult?>): MutableList<List<Mask>?> {
return captureResults.map { result ->
result?.windowEntry?.rootView?.let { rv ->
maskCollector.collectMasks(rv, explicitMaskMatchers, globalMaskMatchers)
maskCollector.collectMasks(
rv,
explicitMaskMatchers,
explicitUnmaskMatchers,
globalMaskMatchers,
)
}
}.toMutableList()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ private val abstractComposeViewClass: Class<*>? by lazy {
* @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 explicitUnmaskMatchers matchers whose match counts as an explicit unmask signal that
* propagates to descendants (e.g. `PrivacyProfile.explicitUnmaskMatchers`).
* @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.
*/
Expand All @@ -36,6 +38,7 @@ data class MaskContext(
val rootX: Float,
val rootY: Float,
val explicitMaskMatchers: List<MaskMatcher>,
val explicitUnmaskMatchers: List<MaskMatcher>,
val globalMaskMatchers: List<MaskMatcher>,
)
/**
Expand All @@ -52,7 +55,8 @@ data class MaskContext(
* 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.
* (via [MaskTarget.hasLDUnmask] or matched by any [MaskContext.explicitUnmaskMatchers]
* entry)? 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.
*
Expand All @@ -68,11 +72,15 @@ class MaskCollector(private val logger: ObserveLogger) {
* @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 explicitUnmaskMatchers matchers whose match counts as an explicit unmask signal that
* propagates to descendants. Pass an empty list when no identifier-based unmasking is
* configured.
* @param globalMaskMatchers matchers whose match applies only to the matched view itself.
*/
fun collectMasks(
root: View,
explicitMaskMatchers: List<MaskMatcher>,
explicitUnmaskMatchers: List<MaskMatcher>,
globalMaskMatchers: List<MaskMatcher>,
): List<Mask> {
val resultMasks = mutableListOf<Mask>()
Expand All @@ -83,6 +91,7 @@ class MaskCollector(private val logger: ObserveLogger) {
rootX = rootX,
rootY = rootY,
explicitMaskMatchers = explicitMaskMatchers,
explicitUnmaskMatchers = explicitUnmaskMatchers,
globalMaskMatchers = globalMaskMatchers,
)

Expand Down Expand Up @@ -217,17 +226,19 @@ class MaskCollector(private val logger: ObserveLogger) {
/**
* 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.
* [MaskContext.explicitMaskMatchers] / [MaskContext.explicitUnmaskMatchers] 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].
* @param context provides the explicit-mask and explicit-unmask matcher lists.
* @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
context.explicitUnmaskMatchers.any { it.isMatch(target) } -> false
else -> null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@ class PrivacyProfileTest {
assertFalse(idSet.contains("@id/baz"))
}

@Test
fun `unmaskXMLViewIds defaults to empty list`() {
val profile = PrivacyProfile()
assertTrue(profile.unmaskXMLViewIds.isEmpty())
}

@Test
fun `unmaskXMLViewIds normalizes @+id and @id prefixes and adds unmaskXMLViewIdsMatcher to explicit unmask matchers`() {
val profile = PrivacyProfile(unmaskXMLViewIds = listOf("@+id/foo", "@id/baz", "bar"))

assertTrue(profile.explicitUnmaskMatchers.contains(profile.unmaskXMLViewIdsMatcher))

val idSet = profile.getPrivateSet("unmaskXMLViewIdSet")
assertTrue(idSet.contains("foo"))
assertTrue(idSet.contains("baz"))
assertTrue(idSet.contains("bar"))
assertFalse(idSet.contains("@+id/foo"))
assertFalse(idSet.contains("@id/baz"))
}

@Test
fun `unmaskXMLViewIds empty does not include unmaskXMLViewIdsMatcher in explicit unmask matchers`() {
val profile = PrivacyProfile(unmaskXMLViewIds = emptyList())
assertTrue(profile.explicitUnmaskMatchers.isEmpty())
}

@Test
fun `maskImageViews adds ImageView to viewClassSet and includes viewsMatcher even when maskViews is empty`() {
val profile = PrivacyProfile(maskImageViews = true, maskViews = emptyList())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ class MaskCollectorTest {
override fun isMatch(target: MaskTarget): Boolean = true
}

/**
* Builds a [MaskMatcher] that matches only when the target wraps the exact [view] reference.
* Useful for verifying inheritance: a matcher that fires on a parent but not its children
* pins the propagation behavior.
*/
private fun matchesOnly(view: View): MaskMatcher = object : MaskMatcher {
override fun isMatch(target: MaskTarget): Boolean = target.view === view
}

/**
* 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
Expand Down Expand Up @@ -58,7 +67,7 @@ class MaskCollectorTest {
val child = mockLeaf()
val parent = mockGroup(child, ldMaskTag = true)

val masks = collector.collectMasks(parent, emptyList(), emptyList())
val masks = collector.collectMasks(parent, emptyList(), emptyList(), emptyList())

// Parent emits a mask via its own hasLDMask; child emits one via inherited mask.
assertEquals(2, masks.size)
Expand All @@ -69,7 +78,7 @@ class MaskCollectorTest {
val child = mockLeaf(ldMaskTag = false)
val parent = mockGroup(child, ldMaskTag = true)

val masks = collector.collectMasks(parent, emptyList(), emptyList())
val masks = collector.collectMasks(parent, emptyList(), emptyList(), emptyList())

// Ancestor mask wins; child's ldUnmask tag is ignored when an ancestor is explicitly masked.
assertEquals(2, masks.size)
Expand All @@ -80,7 +89,7 @@ class MaskCollectorTest {
val child = mockLeaf()
val parent = mockGroup(child, ldMaskTag = false)

val masks = collector.collectMasks(parent, emptyList(), listOf(matchAll))
val masks = collector.collectMasks(parent, emptyList(), 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.
Expand All @@ -94,7 +103,7 @@ class MaskCollectorTest {
// signal wins.
val view = mockLeaf(ldMaskTag = false)

val masks = collector.collectMasks(view, listOf(matchAll), emptyList())
val masks = collector.collectMasks(view, listOf(matchAll), emptyList(), emptyList())

assertEquals(1, masks.size)
}
Expand All @@ -107,7 +116,7 @@ class MaskCollectorTest {
val plainSubtree = mockGroup(plainChild)
val root = mockGroup(unmaskedSubtree, plainSubtree)

val masks = collector.collectMasks(root, emptyList(), listOf(matchAll))
val masks = collector.collectMasks(root, emptyList(), 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,
Expand All @@ -119,8 +128,49 @@ class MaskCollectorTest {
fun `view without explicit signal falls through to global matcher`() {
val view = mockLeaf()

val masks = collector.collectMasks(view, emptyList(), listOf(matchAll))
val masks = collector.collectMasks(view, emptyList(), emptyList(), listOf(matchAll))

assertEquals(1, masks.size)
}

@Test
fun `explicit unmask matcher suppresses global match on the same view`() {
val view = mockLeaf()

// Run the matcher list against a view that matches both unmask and global; explicit
// unmask should win.
val masks = collector.collectMasks(view, emptyList(), listOf(matchAll), listOf(matchAll))

// Explicit unmask vetoes the global match — no mask emitted.
assertEquals(0, masks.size)
}

@Test
fun `explicit unmask matcher on ancestor propagates to descendant`() {
val child = mockLeaf()
val parent = mockGroup(child)

// Only the parent matches the explicit-unmask matcher; the child does not match it
// directly, so any propagation to descendants must come from the precedence rules.
val masks = collector.collectMasks(
parent,
emptyList(),
listOf(matchesOnly(parent)),
listOf(matchAll),
)

// Parent's explicit unmask propagates to the child, suppressing the child's global match.
assertEquals(0, masks.size)
}

@Test
fun `explicit mask matcher wins over explicit unmask matcher on the same view`() {
val view = mockLeaf()

// Both lists match the same view; the precedence order says mask wins on the same level.
val masks = collector.collectMasks(view, listOf(matchAll), listOf(matchAll), emptyList())

// Single mask emitted — the explicit mask matcher beats the explicit unmask matcher.
assertEquals(1, masks.size)
}
}
Loading