Skip to content

Commit 1c57dc0

Browse files
feat: Added privacy options: maskViews, maskXMLViewIds, maskImageViews (#339)
## Summary Added 3 new privacy options: 1. maskViews 2. maskXMLViewIds 3. maskImageViews ## How did you test this change? <img width="1024" height="750" alt="image" src="https://github.com/user-attachments/assets/1796f97c-58e4-489e-a916-2fe9b8157cd0" /> ## Are there any deployment considerations? <!-- Backend - Do we need to consider migrations or backfilling data? --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces configurable session replay masking and updates examples/docs. > > - Adds `MaskViewRef` and `view(...)` helpers to reference target `View` classes by `Class`, `KClass`, or FQCN > - Extends `PrivacyProfile` with `maskViews`, `maskXMLViewIds`, and `maskImageViews`; implements `viewsMatcher` (exact class), `xmlViewIdsMatcher` (resource entry name normalization), and integrates into matcher list > - Updates README: corrects `SessionReplay` import path and documents configuring masking via `PrivacyProfile` > - Example app: configures `SessionReplay` with new `PrivacyProfile` options; comments out a per-view `ldMask()` call > - Tests: adds `PrivacyProfileTest` covering defaults, matching behavior, and invalid class handling; marks one e2e traces test `@Ignore` > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c57d5c2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 346e8fb commit 1c57dc0

File tree

7 files changed

+304
-64
lines changed

7 files changed

+304
-64
lines changed

e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package com.example.androidobservability
22

33
import android.app.Application
4+
import android.widget.ImageView
45
import com.launchdarkly.observability.api.ObservabilityOptions
56
import com.launchdarkly.observability.client.TelemetryInspector
67
import com.launchdarkly.observability.plugin.Observability
78
import com.launchdarkly.observability.replay.PrivacyProfile
89
import com.launchdarkly.observability.replay.ReplayOptions
910
import com.launchdarkly.observability.replay.plugin.SessionReplay
11+
import com.launchdarkly.observability.replay.view
1012
import com.launchdarkly.sdk.ContextKind
1113
import com.launchdarkly.sdk.LDContext
1214
import com.launchdarkly.sdk.android.Components
@@ -49,7 +51,13 @@ open class BaseApplication : Application() {
4951

5052
val sessionReplayPlugin = SessionReplay(
5153
options = ReplayOptions(
52-
privacyProfile = PrivacyProfile(maskText = false)
54+
privacyProfile = PrivacyProfile(
55+
maskText = false,
56+
maskViews = listOf(
57+
view(ImageView::class.java),
58+
view("android.widget.TextView")
59+
),
60+
maskXMLViewIds = listOf("smoothieTitle"))
5361
)
5462
)
5563

e2e/android/app/src/main/java/com/example/androidobservability/smoothie/SmoothieAdapter.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ class SmoothieAdapter(
2222

2323
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
2424
val imageView: ImageView = itemView.findViewById(R.id.smoothieImage)
25-
init {
26-
imageView.ldMask()
27-
}
25+
// init {
26+
// imageView.ldMask()
27+
// }
2828
val titleView: TextView = itemView.findViewById(R.id.smoothieTitle)
2929
}
3030

e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import junit.framework.TestCase.assertEquals
1414
import junit.framework.TestCase.assertFalse
1515
import junit.framework.TestCase.assertNotNull
1616
import junit.framework.TestCase.assertTrue
17+
import org.junit.Ignore
1718
import org.junit.Test
1819
import org.junit.runner.RunWith
1920
import org.robolectric.RobolectricTestRunner
@@ -106,6 +107,7 @@ class DisablingConfigOptionsE2ETest {
106107
}
107108

108109
@Test
110+
@Ignore //https://launchdarkly.atlassian.net/browse/O11Y-885
109111
fun `Spans should be exported when TracesApi is enabled`() {
110112
application.observabilityOptions = getOptionsAllEnabled().copy(tracesApi = ObservabilityOptions.TracesApi.enabled())
111113
application.initForTest()

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ Add the Session Replay plugin **after** Observability when configuring the Launc
202202

203203
```kotlin
204204
import com.launchdarkly.observability.plugin.Observability
205-
import com.launchdarkly.observability.replay.SessionReplay
205+
import com.launchdarkly.observability.replay.plugin.SessionReplay
206206

207207
val ldConfig = LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Enabled)
208208
.mobileKey("your-mobile-key")
@@ -225,6 +225,41 @@ Notes:
225225

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

228+
##### Configure masking via `PrivacyProfile`
229+
230+
If you want to configure masking globally (instead of calling `ldMask()` on each element), pass a `PrivacyProfile` to `ReplayOptions`:
231+
232+
```kotlin
233+
import com.launchdarkly.observability.replay.PrivacyProfile
234+
import com.launchdarkly.observability.replay.ReplayOptions
235+
import com.launchdarkly.observability.replay.view
236+
import com.launchdarkly.observability.replay.plugin.SessionReplay
237+
238+
val sessionReplay = SessionReplay(
239+
ReplayOptions(
240+
privacyProfile = PrivacyProfile(
241+
// New settings:
242+
maskViews = listOf(
243+
// Masks targets by *exact* Android View class (does not match subclasses).
244+
view(android.widget.ImageView::class),
245+
// You can also provide the class name as a string (FQCN).
246+
view("android.widget.EditText"),
247+
),
248+
maskXMLViewIds = listOf(
249+
// Masks by resource entry name (from resources.getResourceEntryName(view.id)).
250+
// Accepts "@+id/foo", "@id/foo", or "foo".
251+
"@+id/password",
252+
"credit_card_number",
253+
),
254+
)
255+
)
256+
)
257+
```
258+
259+
Notes:
260+
- `maskViews` matches on `target.view.javaClass` equality (exact class only).
261+
- `maskXMLViewIds` applies only to Views with a non-`View.NO_ID` id that resolves to a resource entry name.
262+
228263
##### XML Views
229264

230265
Import the masking API and call `ldMask()` on any `View` (for example, after inflating the layout in an `Activity` or `Fragment`).
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.launchdarkly.observability.replay
2+
3+
import kotlin.reflect.KClass
4+
5+
sealed interface MaskViewRef {
6+
val clazz: Class<*>
7+
8+
data class FromClass(
9+
override val clazz: Class<*>
10+
) : MaskViewRef
11+
12+
data class FromKClass(
13+
val kclass: KClass<*>
14+
) : MaskViewRef {
15+
override val clazz: Class<*> = kclass.java
16+
}
17+
18+
data class FromName(
19+
val fullClassName: String
20+
) : MaskViewRef {
21+
override val clazz: Class<*> =
22+
try {
23+
Class.forName(fullClassName)
24+
} catch (e: ClassNotFoundException) {
25+
throw IllegalArgumentException(
26+
"PrivacyProfile.maskViews contains an invalid class name: '$fullClassName'. " +
27+
"Provide a fully-qualified Android View class name (e.g. 'android.widget.TextView').",
28+
e
29+
)
30+
}
31+
}
32+
}
33+
34+
fun view(clazz: Class<*>): MaskViewRef = MaskViewRef.FromClass(clazz)
35+
fun view(kclass: KClass<*>): MaskViewRef = MaskViewRef.FromKClass(kclass)
36+
fun view(name: String): MaskViewRef = MaskViewRef.FromName(name)
Lines changed: 116 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,150 @@
11
package com.launchdarkly.observability.replay
22

3+
import android.view.View
4+
import android.widget.ImageView
35
import com.launchdarkly.observability.replay.masking.MaskMatcher
46
import com.launchdarkly.observability.replay.masking.MaskTarget
57

68
/**
7-
* [PrivacyProfile] encapsulates options and functionality related to privacy of session
8-
* replay functionality.
9+
* [PrivacyProfile] controls what UI elements are masked in session replay.
910
*
10-
* By default, session replay will apply an opaque mask to text inputs, text, and sensitive views.
11-
* See [sensitiveMatcher] for specific details.
11+
* Masking is implemented as a list of [MaskMatcher]s that are evaluated against a [MaskTarget].
12+
* Targets can represent native Android Views as well as Jetpack Compose semantics nodes.
1213
*
13-
* @param maskTextInputs set to false to turn off masking text inputs
14-
* @param maskText set to false to turn off masking text
15-
* @param maskSensitive set to false to turn off masking sensitive views
16-
* @param maskAdditionalMatchers list of additional [com.launchdarkly.observability.replay.masking.MaskMatcher]s that will be masked when they match
14+
*
15+
* @param maskTextInputs Set to false to disable masking text input targets.
16+
* @param maskText Set to false to disable masking text targets.
17+
* @param maskSensitive Set to false to disable masking "sensitive" targets (password + keyword heuristics).
18+
* @param maskImageViews Set to true to mask [ImageView] targets by exact class match.
19+
* @param maskViews Additional Views to mask by exact class match (see [viewsMatcher]).
20+
* @param maskXMLViewIds Additional Views to mask by resource entry name (see [xmlViewIdsMatcher]).
21+
* Accepts `"@+id/foo"`, `"@id/foo"`, or `"foo"`.
22+
* @param maskAdditionalMatchers Additional custom matchers to apply.
1723
**/
1824
data class PrivacyProfile(
1925
val maskTextInputs: Boolean = true,
2026
val maskText: Boolean = true,
2127
val maskSensitive: Boolean = true,
28+
// only for XML ImageViews
29+
val maskImageViews: Boolean = false,
30+
val maskViews: List<MaskViewRef> = emptyList(),
31+
val maskXMLViewIds: List<String> = emptyList(),
2232
val maskAdditionalMatchers: List<MaskMatcher> = emptyList(),
2333
) {
34+
private val viewClassSet = buildSet {
35+
addAll(maskViews.map { it.clazz })
36+
if (maskImageViews) add(ImageView::class.java)
37+
}
38+
39+
private val maskXMLViewIdSet = maskXMLViewIds.map {
40+
when {
41+
it.startsWith("@+id/") -> it.substring(5)
42+
it.startsWith("@id/") -> it.substring(4)
43+
else -> it
44+
}
45+
}.toSet()
2446

2547
/**
2648
* Converts this [PrivacyProfile] into its equivalent [MaskMatcher] list.
49+
*
50+
* Note: matchers are evaluated with `any { ... }`, so ordering only affects performance
51+
* (earlier matchers can short-circuit later ones).
2752
*/
2853
internal fun asMatchersList(): List<MaskMatcher> = buildList {
54+
// Prefer cheaper checks first; heavier checks should be later.
2955
if (maskTextInputs) add(textInputMatcher)
3056
if (maskText) add(textMatcher)
57+
if (viewClassSet.isNotEmpty()) add(viewsMatcher)
58+
if (maskXMLViewIdSet.isNotEmpty()) add(xmlViewIdsMatcher)
3159
if (maskSensitive) add(sensitiveMatcher)
3260
addAll(maskAdditionalMatchers)
3361
}
3462

35-
companion object {
36-
/**
37-
* This matcher will match most text inputs, but there may be special cases where it will
38-
* miss as we can't account for all possible future semantic properties.
39-
*/
40-
val textInputMatcher: MaskMatcher = object : MaskMatcher {
41-
override fun isMatch(target: MaskTarget): Boolean {
42-
return target.isTextInput()
43-
}
63+
/**
64+
* Matches targets whose underlying Android View has an exact class match with [maskViews].
65+
*
66+
* Note: this uses `target.view.javaClass` equality; it does not match subclasses.
67+
*/
68+
val viewsMatcher: MaskMatcher = object : MaskMatcher {
69+
override fun isMatch(target: MaskTarget): Boolean {
70+
return viewClassSet.contains(target.view.javaClass)
4471
}
72+
}
4573

46-
/**
47-
* This matcher will match most text, but there may be special cases where it will
48-
* miss as we can't account for all possible future semantic properties.
49-
*/
50-
val textMatcher: MaskMatcher = object : MaskMatcher {
51-
override fun isMatch(target: MaskTarget): Boolean {
52-
return target.isText()
53-
}
74+
/**
75+
* Matches targets whose underlying Android View's resource entry name is included in
76+
* [maskXMLViewIds].
77+
*
78+
* IDs are compared using `resources.getResourceEntryName(view.id)`, so this only applies to
79+
* Views with a non-[View.NO_ID] id that resolves to a resource entry.
80+
*/
81+
val xmlViewIdsMatcher: MaskMatcher = object : MaskMatcher {
82+
fun View.idNameOrNull(): String? =
83+
if (id == View.NO_ID) null
84+
else runCatching { resources.getResourceEntryName(id) }.getOrNull()
85+
86+
override fun isMatch(target: MaskTarget): Boolean {
87+
val id = target.view.idNameOrNull() ?: return false
88+
89+
return maskXMLViewIdSet.contains(id)
5490
}
91+
}
5592

56-
/**
57-
* This matcher will match all items having the semantic property [SemanticsProperties.Password]
58-
* and all text or context descriptions that have substring matches with any of the [sensitiveKeywords]
59-
*/
60-
val sensitiveMatcher: MaskMatcher = object : MaskMatcher {
61-
override fun isMatch(target: MaskTarget): Boolean {
62-
return target.isSensitive(sensitiveKeywords)
63-
}
93+
/**
94+
* This matcher will match most text inputs, but there may be special cases where it will
95+
* miss as we can't account for all possible future semantic properties.
96+
*/
97+
val textInputMatcher: MaskMatcher = object : MaskMatcher {
98+
override fun isMatch(target: MaskTarget): Boolean {
99+
return target.isTextInput()
100+
}
101+
}
102+
103+
/**
104+
* This matcher will match most text, but there may be special cases where it will
105+
* miss as we can't account for all possible future semantic properties.
106+
*/
107+
val textMatcher: MaskMatcher = object : MaskMatcher {
108+
override fun isMatch(target: MaskTarget): Boolean {
109+
return target.isText()
64110
}
111+
}
65112

66-
// this list of sensitive keywords is used to detect sensitive content descriptions
67-
private val sensitiveKeywords = listOf(
68-
"sensitive",
69-
"private",
70-
"name",
71-
"email",
72-
"username",
73-
"cell",
74-
"mobile",
75-
"phone",
76-
"address",
77-
"street",
78-
"dob",
79-
"birth",
80-
"password",
81-
"account",
82-
"ssn",
83-
"social",
84-
"security",
85-
"credit",
86-
"debit",
87-
"card",
88-
"cvv",
89-
"mm/yy",
90-
"pin",
91-
)
113+
/**
114+
* This matcher will match all items having the semantic property
115+
* and all text or context descriptions that have substring matches with any of the [sensitiveKeywords]
116+
*/
117+
val sensitiveMatcher: MaskMatcher = object : MaskMatcher {
118+
override fun isMatch(target: MaskTarget): Boolean {
119+
return target.isSensitive(sensitiveKeywords)
120+
}
92121
}
122+
123+
// this list of sensitive keywords is used to detect sensitive content descriptions
124+
private val sensitiveKeywords = listOf(
125+
"sensitive",
126+
"private",
127+
"name",
128+
"email",
129+
"username",
130+
"cell",
131+
"mobile",
132+
"phone",
133+
"address",
134+
"street",
135+
"dob",
136+
"birth",
137+
"password",
138+
"account",
139+
"ssn",
140+
"social",
141+
"security",
142+
"credit",
143+
"debit",
144+
"card",
145+
"cvv",
146+
"mm/yy",
147+
"pin",
148+
)
93149
}
150+

0 commit comments

Comments
 (0)