Skip to content

Commit 5831209

Browse files
authored
feat(android): add unmaskXMLViewIds to PrivacyProfile (#522)
## Summary Adds Android `unmaskXMLViewIds` option that is equivalent to iOS's `unmaskAccessibilityIdentifiers` ## How did you test this change? Unit tests added. ## Are there any deployment considerations? No. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes session-replay masking behavior by introducing a new explicit-unmask path and updating `MaskCollector.collectMasks` call signatures, which could alter what gets redacted if misconfigured. > > **Overview** > Adds `unmaskXMLViewIds` to `PrivacyProfile` so specific XML Views can be explicitly *unmasked* by resource entry name, overriding global masking rules while still losing to explicit masks. > > Updates the masking pipeline to pass and evaluate a new `explicitUnmaskMatchers` list in `MaskCollector` (including precedence/propagation semantics), wires it through `ImageCaptureService`, and documents the new option in the README. > > Extends unit tests to cover id normalization, matcher inclusion, and explicit-unmask precedence/propagation behaviors. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 3af5a9f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4299d1e commit 5831209

6 files changed

Lines changed: 170 additions & 27 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ When deciding whether a specific view should be masked in a Session Replay, the
250250

251251
1. **Explicit Masking (Highest Priority)**: Is the view, or *any* of its parent views, explicitly masked (e.g., using `.ldMask()` or matching `maskXMLViewIds`)?
252252
* **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()`)?
253+
2. **Explicit Unmasking**: Is the view, or *any* of its parent views, explicitly unmasked (e.g., using `.ldUnmask()` or matching `unmaskXMLViewIds`)?
254254
* **Yes**: The view is **unmasked**.
255255
3. **Global Configuration**: Does your global privacy configuration (like `maskTextInputs`, `maskImages`, etc.) apply to this view?
256256
* **Yes**: The view follows the global configuration.
@@ -289,6 +289,12 @@ val sessionReplay = SessionReplay(
289289
"@+id/password",
290290
"credit_card_number",
291291
),
292+
unmaskXMLViewIds = listOf(
293+
// Unmasks views matching these ids. Same id format as maskXMLViewIds. Takes
294+
// precedence over global rules like `maskText`/`maskTextInputs`, but an explicit
295+
// mask on the same view or any of its ancestors still wins.
296+
"@+id/greeting",
297+
),
292298
)
293299
)
294300
)

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

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ import android.widget.ImageView
66
import com.launchdarkly.observability.replay.masking.MaskMatcher
77
import com.launchdarkly.observability.replay.masking.MaskTarget
88

9+
/**
10+
* Normalizes a list of Android XML view id strings into the form returned by
11+
* `Resources.getResourceEntryName()`. Strips the `@+id/` and `@id/` prefixes; bare names
12+
* (`"foo"`) are passed through unchanged. Returns the result as a Set for O(1) lookup.
13+
*/
14+
private fun List<String>.normalizeXmlIds(): Set<String> = map {
15+
when {
16+
it.startsWith("@+id/") -> it.substring(5)
17+
it.startsWith("@id/") -> it.substring(4)
18+
else -> it
19+
}
20+
}.toSet()
21+
922
/**
1023
* [PrivacyProfile] controls what UI elements are masked in session replay.
1124
*
@@ -19,6 +32,9 @@ import com.launchdarkly.observability.replay.masking.MaskTarget
1932
* @param maskViews Additional Views to mask by exact class match (see [viewsMatcher]).
2033
* @param maskXMLViewIds Additional Views to mask by resource entry name (see [xmlViewIdsMatcher]).
2134
* accepts `"@+id/foo"`, `"@id/foo"`, or `"foo"`.
35+
* @param unmaskXMLViewIds Views whose resource entry name appears in this list are explicitly
36+
* *unmasked* (see [unmaskXMLViewIdsMatcher]). Same id format as [maskXMLViewIds]. Takes precedence
37+
* over global masking rules — see `MaskCollector` for the full precedence rules.
2238
* @param maskWebViews Set to true to mask known WebView types and their subclasses
2339
* (e.g., "android.webkit.WebView", "org.mozilla.geckoview.GeckoView", etc).
2440
* @param maskBySemanticsKeywords Set to true to enable masking of "sensitive" targets detected by
@@ -29,6 +45,7 @@ data class PrivacyProfile(
2945
val maskText: Boolean = false,
3046
val maskViews: List<MaskViewRef> = emptyList(),
3147
val maskXMLViewIds: List<String> = emptyList(),
48+
val unmaskXMLViewIds: List<String> = emptyList(),
3249
// only for XML ImageViews
3350
val maskImageViews: Boolean = false,
3451
val maskWebViews: Boolean = false,
@@ -41,13 +58,8 @@ data class PrivacyProfile(
4158

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

44-
private val maskXMLViewIdSet = maskXMLViewIds.map {
45-
when {
46-
it.startsWith("@+id/") -> it.substring(5)
47-
it.startsWith("@id/") -> it.substring(4)
48-
else -> it
49-
}
50-
}.toSet()
61+
private val maskXMLViewIdSet = maskXMLViewIds.normalizeXmlIds()
62+
private val unmaskXMLViewIdSet = unmaskXMLViewIds.normalizeXmlIds()
5163

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

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

90102
override fun isMatch(target: MaskTarget): Boolean {
91103
val id = target.view.idNameOrNull() ?: return false
92-
93-
return maskXMLViewIdSet.contains(id)
104+
return idSet.contains(id)
94105
}
95106
}
96107

108+
/**
109+
* Matches targets whose underlying Android View's resource entry name is included in
110+
* [maskXMLViewIds].
111+
*
112+
* IDs are compared using `resources.getResourceEntryName(view.id)`, so this only applies to
113+
* Views with a non-[View.NO_ID] id that resolves to a resource entry.
114+
*/
115+
internal val xmlViewIdsMatcher: MaskMatcher = xmlIdMatcher(maskXMLViewIdSet)
116+
117+
/**
118+
* Matches targets whose underlying Android View's resource entry name is included in
119+
* [unmaskXMLViewIds]. Same id resolution as [xmlViewIdsMatcher].
120+
*/
121+
internal val unmaskXMLViewIdsMatcher: MaskMatcher = xmlIdMatcher(unmaskXMLViewIdSet)
122+
97123
/**
98124
* This matcher will match most text inputs, but there may be special cases where it will
99125
* miss as we can't account for all possible future semantic properties.
@@ -139,6 +165,19 @@ data class PrivacyProfile(
139165
if (maskXMLViewIdSet.isNotEmpty()) add(xmlViewIdsMatcher)
140166
}
141167

168+
/**
169+
* Matchers whose match counts as an "explicit" unmask signal — equivalent to a call to
170+
* `View.ldUnmask()` on the matched view. An explicit-unmask match propagates to descendants
171+
* per the precedence rules in `MaskCollector`. An ancestor's explicit mask still wins over
172+
* an explicit unmask.
173+
*
174+
* Matchers are evaluated with `any { ... }`, so ordering only affects performance (earlier
175+
* matchers can short-circuit later ones).
176+
*/
177+
internal val explicitUnmaskMatchers: List<MaskMatcher> = buildList {
178+
if (unmaskXMLViewIdSet.isNotEmpty()) add(unmaskXMLViewIdsMatcher)
179+
}
180+
142181
/**
143182
* Matchers whose match applies only to the matched view itself: a global match does not
144183
* propagate to descendants and does not override an explicit unmask.

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class ImageCaptureService(
4747
private val maskCollector = MaskCollector(logger)
4848
private val maskApplier = MaskApplier()
4949
private val explicitMaskMatchers = options.privacyProfile.explicitMaskMatchers
50+
private val explicitUnmaskMatchers = options.privacyProfile.explicitUnmaskMatchers
5051
private val globalMaskMatchers = options.privacyProfile.globalMaskMatchers
5152

5253
suspend fun captureRawFrame(): RawFrame? =
@@ -176,14 +177,24 @@ class ImageCaptureService(
176177

177178
private fun collectMasks(capturingWindowEntries: List<WindowEntry>): MutableList<List<Mask>?> {
178179
return capturingWindowEntries.map {
179-
maskCollector.collectMasks(it.rootView, explicitMaskMatchers, globalMaskMatchers)
180+
maskCollector.collectMasks(
181+
it.rootView,
182+
explicitMaskMatchers,
183+
explicitUnmaskMatchers,
184+
globalMaskMatchers,
185+
)
180186
}.toMutableList()
181187
}
182188

183189
private fun collectMasksFromResults(captureResults: List<CaptureResult?>): MutableList<List<Mask>?> {
184190
return captureResults.map { result ->
185191
result?.windowEntry?.rootView?.let { rv ->
186-
maskCollector.collectMasks(rv, explicitMaskMatchers, globalMaskMatchers)
192+
maskCollector.collectMasks(
193+
rv,
194+
explicitMaskMatchers,
195+
explicitUnmaskMatchers,
196+
globalMaskMatchers,
197+
)
187198
}
188199
}.toMutableList()
189200
}

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ private val abstractComposeViewClass: Class<*>? by lazy {
2828
* @param rootY y-coordinate of the root view in screen space.
2929
* @param explicitMaskMatchers matchers whose match counts as an explicit mask signal that
3030
* propagates to descendants (e.g. `PrivacyProfile.explicitMaskMatchers`).
31+
* @param explicitUnmaskMatchers matchers whose match counts as an explicit unmask signal that
32+
* propagates to descendants (e.g. `PrivacyProfile.explicitUnmaskMatchers`).
3133
* @param globalMaskMatchers matchers whose match applies only to the matched view itself; they do
3234
* not propagate to descendants and do not override an explicit unmask.
3335
*/
@@ -36,6 +38,7 @@ data class MaskContext(
3638
val rootX: Float,
3739
val rootY: Float,
3840
val explicitMaskMatchers: List<MaskMatcher>,
41+
val explicitUnmaskMatchers: List<MaskMatcher>,
3942
val globalMaskMatchers: List<MaskMatcher>,
4043
)
4144
/**
@@ -52,7 +55,8 @@ data class MaskContext(
5255
* explicitly masked (via [MaskTarget.hasLDMask] or matched by any
5356
* [MaskContext.explicitMaskMatchers] entry)? If so, the target is masked.
5457
* 2. **Explicit Unmasking.** Is the target — or any of its ancestors — explicitly unmasked
55-
* (via [MaskTarget.hasLDUnmask])? If so, the target is not masked.
58+
* (via [MaskTarget.hasLDUnmask] or matched by any [MaskContext.explicitUnmaskMatchers]
59+
* entry)? If so, the target is not masked.
5660
* 3. **Global configuration.** Does any [MaskContext.globalMaskMatchers] entry match the
5761
* target? If so, the target is masked. Global matches do not propagate to descendants.
5862
*
@@ -68,11 +72,15 @@ class MaskCollector(private val logger: ObserveLogger) {
6872
* @param explicitMaskMatchers matchers whose match counts as an explicit mask signal that
6973
* propagates to descendants. Pass an empty list when no identifier-based masking is
7074
* configured.
75+
* @param explicitUnmaskMatchers matchers whose match counts as an explicit unmask signal that
76+
* propagates to descendants. Pass an empty list when no identifier-based unmasking is
77+
* configured.
7178
* @param globalMaskMatchers matchers whose match applies only to the matched view itself.
7279
*/
7380
fun collectMasks(
7481
root: View,
7582
explicitMaskMatchers: List<MaskMatcher>,
83+
explicitUnmaskMatchers: List<MaskMatcher>,
7684
globalMaskMatchers: List<MaskMatcher>,
7785
): List<Mask> {
7886
val resultMasks = mutableListOf<Mask>()
@@ -83,6 +91,7 @@ class MaskCollector(private val logger: ObserveLogger) {
8391
rootX = rootX,
8492
rootY = rootY,
8593
explicitMaskMatchers = explicitMaskMatchers,
94+
explicitUnmaskMatchers = explicitUnmaskMatchers,
8695
globalMaskMatchers = globalMaskMatchers,
8796
)
8897

@@ -217,17 +226,19 @@ class MaskCollector(private val logger: ObserveLogger) {
217226
/**
218227
* The target's *own* explicit signal, ignoring ancestors. Per-view markers
219228
* ([MaskTarget.hasLDMask] / [MaskTarget.hasLDUnmask]) and any
220-
* [MaskContext.explicitMaskMatchers] entry that matches all count as explicit signals; mask
221-
* wins over unmask if both are present on the same target.
229+
* [MaskContext.explicitMaskMatchers] / [MaskContext.explicitUnmaskMatchers] entry that
230+
* matches all count as explicit signals; mask wins over unmask if both are present on the
231+
* same target.
222232
*
223233
* @param target the target to inspect.
224-
* @param context provides [MaskContext.explicitMaskMatchers].
234+
* @param context provides the explicit-mask and explicit-unmask matcher lists.
225235
* @return `true` for explicit mask, `false` for explicit unmask, `null` for no signal.
226236
*/
227237
private fun explicitOf(target: MaskTarget, context: MaskContext): Boolean? = when {
228238
target.hasLDMask() -> true
229239
context.explicitMaskMatchers.any { it.isMatch(target) } -> true
230240
target.hasLDUnmask() -> false
241+
context.explicitUnmaskMatchers.any { it.isMatch(target) } -> false
231242
else -> null
232243
}
233244

sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/PrivacyProfileTest.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,32 @@ class PrivacyProfileTest {
5757
assertFalse(idSet.contains("@id/baz"))
5858
}
5959

60+
@Test
61+
fun `unmaskXMLViewIds defaults to empty list`() {
62+
val profile = PrivacyProfile()
63+
assertTrue(profile.unmaskXMLViewIds.isEmpty())
64+
}
65+
66+
@Test
67+
fun `unmaskXMLViewIds normalizes @+id and @id prefixes and adds unmaskXMLViewIdsMatcher to explicit unmask matchers`() {
68+
val profile = PrivacyProfile(unmaskXMLViewIds = listOf("@+id/foo", "@id/baz", "bar"))
69+
70+
assertTrue(profile.explicitUnmaskMatchers.contains(profile.unmaskXMLViewIdsMatcher))
71+
72+
val idSet = profile.getPrivateSet("unmaskXMLViewIdSet")
73+
assertTrue(idSet.contains("foo"))
74+
assertTrue(idSet.contains("baz"))
75+
assertTrue(idSet.contains("bar"))
76+
assertFalse(idSet.contains("@+id/foo"))
77+
assertFalse(idSet.contains("@id/baz"))
78+
}
79+
80+
@Test
81+
fun `unmaskXMLViewIds empty does not include unmaskXMLViewIdsMatcher in explicit unmask matchers`() {
82+
val profile = PrivacyProfile(unmaskXMLViewIds = emptyList())
83+
assertTrue(profile.explicitUnmaskMatchers.isEmpty())
84+
}
85+
6086
@Test
6187
fun `maskImageViews adds ImageView to viewClassSet and includes viewsMatcher even when maskViews is empty`() {
6288
val profile = PrivacyProfile(maskImageViews = true, maskViews = emptyList())

sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/masking/MaskCollectorTest.kt

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ class MaskCollectorTest {
1818
override fun isMatch(target: MaskTarget): Boolean = true
1919
}
2020

21+
/**
22+
* Builds a [MaskMatcher] that matches only when the target wraps the exact [view] reference.
23+
* Useful for verifying inheritance: a matcher that fires on a parent but not its children
24+
* pins the propagation behavior.
25+
*/
26+
private fun matchesOnly(view: View): MaskMatcher = object : MaskMatcher {
27+
override fun isMatch(target: MaskTarget): Boolean = target.view === view
28+
}
29+
2130
/**
2231
* Builds a mocked leaf [View] with a controllable per-view masking signal. width/height are
2332
* positive so [NativeMaskTarget.mask] returns a non-null Mask, and `isShown=true` so
@@ -58,7 +67,7 @@ class MaskCollectorTest {
5867
val child = mockLeaf()
5968
val parent = mockGroup(child, ldMaskTag = true)
6069

61-
val masks = collector.collectMasks(parent, emptyList(), emptyList())
70+
val masks = collector.collectMasks(parent, emptyList(), emptyList(), emptyList())
6271

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

72-
val masks = collector.collectMasks(parent, emptyList(), emptyList())
81+
val masks = collector.collectMasks(parent, emptyList(), emptyList(), emptyList())
7382

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

83-
val masks = collector.collectMasks(parent, emptyList(), listOf(matchAll))
92+
val masks = collector.collectMasks(parent, emptyList(), emptyList(), listOf(matchAll))
8493

8594
// Inherited unmask suppresses the global match on the descendant; the parent itself is
8695
// also not masked because its own ldUnmask vetoes the global matcher.
@@ -94,7 +103,7 @@ class MaskCollectorTest {
94103
// signal wins.
95104
val view = mockLeaf(ldMaskTag = false)
96105

97-
val masks = collector.collectMasks(view, listOf(matchAll), emptyList())
106+
val masks = collector.collectMasks(view, listOf(matchAll), emptyList(), emptyList())
98107

99108
assertEquals(1, masks.size)
100109
}
@@ -107,7 +116,7 @@ class MaskCollectorTest {
107116
val plainSubtree = mockGroup(plainChild)
108117
val root = mockGroup(unmaskedSubtree, plainSubtree)
109118

110-
val masks = collector.collectMasks(root, emptyList(), listOf(matchAll))
119+
val masks = collector.collectMasks(root, emptyList(), emptyList(), listOf(matchAll))
111120

112121
// root + plainSubtree + plainChild all match the global matcher (3 masks). The
113122
// unmaskedSubtree branch carries an explicit unmask that propagates down to its child,
@@ -119,8 +128,49 @@ class MaskCollectorTest {
119128
fun `view without explicit signal falls through to global matcher`() {
120129
val view = mockLeaf()
121130

122-
val masks = collector.collectMasks(view, emptyList(), listOf(matchAll))
131+
val masks = collector.collectMasks(view, emptyList(), emptyList(), listOf(matchAll))
132+
133+
assertEquals(1, masks.size)
134+
}
135+
136+
@Test
137+
fun `explicit unmask matcher suppresses global match on the same view`() {
138+
val view = mockLeaf()
139+
140+
// Run the matcher list against a view that matches both unmask and global; explicit
141+
// unmask should win.
142+
val masks = collector.collectMasks(view, emptyList(), listOf(matchAll), listOf(matchAll))
143+
144+
// Explicit unmask vetoes the global match — no mask emitted.
145+
assertEquals(0, masks.size)
146+
}
147+
148+
@Test
149+
fun `explicit unmask matcher on ancestor propagates to descendant`() {
150+
val child = mockLeaf()
151+
val parent = mockGroup(child)
152+
153+
// Only the parent matches the explicit-unmask matcher; the child does not match it
154+
// directly, so any propagation to descendants must come from the precedence rules.
155+
val masks = collector.collectMasks(
156+
parent,
157+
emptyList(),
158+
listOf(matchesOnly(parent)),
159+
listOf(matchAll),
160+
)
161+
162+
// Parent's explicit unmask propagates to the child, suppressing the child's global match.
163+
assertEquals(0, masks.size)
164+
}
165+
166+
@Test
167+
fun `explicit mask matcher wins over explicit unmask matcher on the same view`() {
168+
val view = mockLeaf()
169+
170+
// Both lists match the same view; the precedence order says mask wins on the same level.
171+
val masks = collector.collectMasks(view, listOf(matchAll), listOf(matchAll), emptyList())
123172

173+
// Single mask emitted — the explicit mask matcher beats the explicit unmask matcher.
124174
assertEquals(1, masks.size)
125175
}
126176
}

0 commit comments

Comments
 (0)