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
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.example.androidobservability

import android.app.Application
import android.widget.ImageView
import com.launchdarkly.observability.api.ObservabilityOptions
import com.launchdarkly.observability.client.TelemetryInspector
import com.launchdarkly.observability.plugin.Observability
import com.launchdarkly.observability.replay.PrivacyProfile
import com.launchdarkly.observability.replay.ReplayOptions
import com.launchdarkly.observability.replay.plugin.SessionReplay
import com.launchdarkly.observability.replay.view
import com.launchdarkly.sdk.ContextKind
import com.launchdarkly.sdk.LDContext
import com.launchdarkly.sdk.android.Components
Expand Down Expand Up @@ -49,7 +51,13 @@ open class BaseApplication : Application() {

val sessionReplayPlugin = SessionReplay(
options = ReplayOptions(
privacyProfile = PrivacyProfile(maskText = false)
privacyProfile = PrivacyProfile(
maskText = false,
maskViews = listOf(
view(ImageView::class.java),
view("android.widget.TextView")
),
maskXMLViewIds = listOf("smoothieTitle"))
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ class SmoothieAdapter(

class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val imageView: ImageView = itemView.findViewById(R.id.smoothieImage)
init {
imageView.ldMask()
}
// init {
// imageView.ldMask()
// }
val titleView: TextView = itemView.findViewById(R.id.smoothieTitle)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertTrue
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
Expand Down Expand Up @@ -106,6 +107,7 @@ class DisablingConfigOptionsE2ETest {
}

@Test
@Ignore //https://launchdarkly.atlassian.net/browse/O11Y-885
fun `Spans should be exported when TracesApi is enabled`() {
application.observabilityOptions = getOptionsAllEnabled().copy(tracesApi = ObservabilityOptions.TracesApi.enabled())
application.initForTest()
Expand Down
37 changes: 36 additions & 1 deletion sdk/@launchdarkly/observability-android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ Add the Session Replay plugin **after** Observability when configuring the Launc

```kotlin
import com.launchdarkly.observability.plugin.Observability
import com.launchdarkly.observability.replay.SessionReplay
import com.launchdarkly.observability.replay.plugin.SessionReplay

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

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

##### Configure masking via `PrivacyProfile`

If you want to configure masking globally (instead of calling `ldMask()` on each element), pass a `PrivacyProfile` to `ReplayOptions`:

```kotlin
import com.launchdarkly.observability.replay.PrivacyProfile
import com.launchdarkly.observability.replay.ReplayOptions
import com.launchdarkly.observability.replay.view
import com.launchdarkly.observability.replay.plugin.SessionReplay

val sessionReplay = SessionReplay(
ReplayOptions(
privacyProfile = PrivacyProfile(
// New settings:
maskViews = listOf(
// Masks targets by *exact* Android View class (does not match subclasses).
view(android.widget.ImageView::class),
// You can also provide the class name as a string (FQCN).
view("android.widget.EditText"),
),
maskXMLViewIds = listOf(
// Masks by resource entry name (from resources.getResourceEntryName(view.id)).
// Accepts "@+id/foo", "@id/foo", or "foo".
"@+id/password",
"credit_card_number",
),
)
)
)
```

Notes:
- `maskViews` matches on `target.view.javaClass` equality (exact class only).
- `maskXMLViewIds` applies only to Views with a non-`View.NO_ID` id that resolves to a resource entry name.

##### XML Views

Import the masking API and call `ldMask()` on any `View` (for example, after inflating the layout in an `Activity` or `Fragment`).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.launchdarkly.observability.replay

import kotlin.reflect.KClass

sealed interface MaskViewRef {
val clazz: Class<*>

data class FromClass(
override val clazz: Class<*>
) : MaskViewRef

data class FromKClass(
val kclass: KClass<*>
) : MaskViewRef {
override val clazz: Class<*> = kclass.java
}

data class FromName(
val fullClassName: String
) : MaskViewRef {
override val clazz: Class<*> =
try {
Class.forName(fullClassName)
} catch (e: ClassNotFoundException) {
throw IllegalArgumentException(
"PrivacyProfile.maskViews contains an invalid class name: '$fullClassName'. " +
"Provide a fully-qualified Android View class name (e.g. 'android.widget.TextView').",
e
)
}
}
}

fun view(clazz: Class<*>): MaskViewRef = MaskViewRef.FromClass(clazz)
fun view(kclass: KClass<*>): MaskViewRef = MaskViewRef.FromKClass(kclass)
fun view(name: String): MaskViewRef = MaskViewRef.FromName(name)
Original file line number Diff line number Diff line change
@@ -1,93 +1,150 @@
package com.launchdarkly.observability.replay

import android.view.View
import android.widget.ImageView
import com.launchdarkly.observability.replay.masking.MaskMatcher
import com.launchdarkly.observability.replay.masking.MaskTarget

/**
* [PrivacyProfile] encapsulates options and functionality related to privacy of session
* replay functionality.
* [PrivacyProfile] controls what UI elements are masked in session replay.
*
* By default, session replay will apply an opaque mask to text inputs, text, and sensitive views.
* See [sensitiveMatcher] for specific details.
* Masking is implemented as a list of [MaskMatcher]s that are evaluated against a [MaskTarget].
* Targets can represent native Android Views as well as Jetpack Compose semantics nodes.
*
* @param maskTextInputs set to false to turn off masking text inputs
* @param maskText set to false to turn off masking text
* @param maskSensitive set to false to turn off masking sensitive views
* @param maskAdditionalMatchers list of additional [com.launchdarkly.observability.replay.masking.MaskMatcher]s that will be masked when they match
*
* @param maskTextInputs Set to false to disable masking text input targets.
* @param maskText Set to false to disable masking text targets.
* @param maskSensitive Set to false to disable masking "sensitive" targets (password + keyword heuristics).
* @param maskImageViews Set to true to mask [ImageView] targets by exact class match.
* @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 maskAdditionalMatchers Additional custom matchers to apply.
**/
data class PrivacyProfile(
val maskTextInputs: Boolean = true,
val maskText: Boolean = true,
val maskSensitive: Boolean = true,
// only for XML ImageViews
val maskImageViews: Boolean = false,
val maskViews: List<MaskViewRef> = emptyList(),
val maskXMLViewIds: List<String> = emptyList(),
val maskAdditionalMatchers: List<MaskMatcher> = emptyList(),
) {
private val viewClassSet = buildSet {
addAll(maskViews.map { it.clazz })
if (maskImageViews) add(ImageView::class.java)
}

private val maskXMLViewIdSet = maskXMLViewIds.map {
when {
it.startsWith("@+id/") -> it.substring(5)
it.startsWith("@id/") -> it.substring(4)
else -> it
}
}.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 (maskSensitive) add(sensitiveMatcher)
addAll(maskAdditionalMatchers)
}

companion object {
/**
* 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.
*/
val textInputMatcher: MaskMatcher = object : MaskMatcher {
override fun isMatch(target: MaskTarget): Boolean {
return target.isTextInput()
}
/**
* Matches targets whose underlying Android View has an exact class match with [maskViews].
*
* Note: this uses `target.view.javaClass` equality; it does not match subclasses.
*/
val viewsMatcher: MaskMatcher = object : MaskMatcher {
override fun isMatch(target: MaskTarget): Boolean {
return viewClassSet.contains(target.view.javaClass)
}
}

/**
* This matcher will match most text, but there may be special cases where it will
* miss as we can't account for all possible future semantic properties.
*/
val textMatcher: MaskMatcher = object : MaskMatcher {
override fun isMatch(target: MaskTarget): Boolean {
return target.isText()
}
/**
* 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.
*/
val xmlViewIdsMatcher: 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)
}
}

/**
* This matcher will match all items having the semantic property [SemanticsProperties.Password]
* and all text or context descriptions that have substring matches with any of the [sensitiveKeywords]
*/
val sensitiveMatcher: MaskMatcher = object : MaskMatcher {
override fun isMatch(target: MaskTarget): Boolean {
return target.isSensitive(sensitiveKeywords)
}
/**
* 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.
*/
val textInputMatcher: MaskMatcher = object : MaskMatcher {
override fun isMatch(target: MaskTarget): Boolean {
return target.isTextInput()
}
}

/**
* This matcher will match most text, but there may be special cases where it will
* miss as we can't account for all possible future semantic properties.
*/
val textMatcher: MaskMatcher = object : MaskMatcher {
override fun isMatch(target: MaskTarget): Boolean {
return target.isText()
}
}

// this list of sensitive keywords is used to detect sensitive content descriptions
private val sensitiveKeywords = listOf(
"sensitive",
"private",
"name",
"email",
"username",
"cell",
"mobile",
"phone",
"address",
"street",
"dob",
"birth",
"password",
"account",
"ssn",
"social",
"security",
"credit",
"debit",
"card",
"cvv",
"mm/yy",
"pin",
)
/**
* This matcher will match all items having the semantic property
* and all text or context descriptions that have substring matches with any of the [sensitiveKeywords]
*/
val sensitiveMatcher: MaskMatcher = object : MaskMatcher {
override fun isMatch(target: MaskTarget): Boolean {
return target.isSensitive(sensitiveKeywords)
}
}

// this list of sensitive keywords is used to detect sensitive content descriptions
private val sensitiveKeywords = listOf(
"sensitive",
"private",
"name",
"email",
"username",
"cell",
"mobile",
"phone",
"address",
"street",
"dob",
"birth",
"password",
"account",
"ssn",
"social",
"security",
"credit",
"debit",
"card",
"cvv",
"mm/yy",
"pin",
)
}

Loading
Loading