Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,10 @@ open class BaseApplication : Application() {

val sessionReplayPlugin = SessionReplay(
options = ReplayOptions(
privacyProfile = PrivacyProfile(maskText = false)
privacyProfile = PrivacyProfile(
maskText = false,
maskViews = listOf(view(ImageView::class.java)),
maskXMLViewIds = listOf("@+id/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
@@ -0,0 +1,27 @@
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 fqcn: String
) : MaskViewRef {
override val clazz: Class<*> = Class.forName(fqcn)
}
}

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,143 @@
package com.launchdarkly.observability.replay

import android.view.View
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 maskViews Additional Views to mask by exact class match (see [viewsMatcher]).
* @param maskXMLViewIds Additional Views to mask by resource entry name (see [xmlViewIdsMatcher]).
* Accepts either `"@+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,
val maskViews: List<MaskViewRef> = emptyList(),
val maskXMLViewIds: List<String> = emptyList(),
val maskAdditionalMatchers: List<MaskMatcher> = emptyList(),
) {
private val viewClassSet = mutableSetOf<Class<*>>()
private val maskXMLViewIdSet = mutableSetOf<String>()

/**
* 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 {
viewClassSet.addAll(maskViews.map { it.clazz })
maskXMLViewIdSet.addAll(maskXMLViewIds.map {
if (it.startsWith("@+id/")) return@map it.substring(5)

return@map it
})

// Prefer cheaper checks first; heavier checks should be later.
if (maskTextInputs) add(textInputMatcher)
if (maskText) add(textMatcher)
if (viewClassSet.isNotEmpty()) add(viewsMatcher)
if (maskXMLViewIds.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)
}
}

/**
* 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 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 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 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, 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",
)
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.launchdarkly.observability.replay

import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class PrivacyProfileTest {

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

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

@Test
fun `maskViews populates viewClassSet and adds viewsMatcher to matchers list`() {
val maskedClass = FakeMaskedView::class.java
val profile = PrivacyProfile(maskViews = listOf(view(maskedClass)))

val matchers = profile.asMatchersList()
assertTrue(matchers.contains(profile.viewsMatcher))

val viewClassSet = profile.getPrivateSet("viewClassSet")
assertTrue(viewClassSet.contains(maskedClass))
}

@Test
fun `maskXMLViewIds normalizes @+id prefix and adds xmlViewIdsMatcher to matchers list`() {
val profile = PrivacyProfile(maskXMLViewIds = listOf("@+id/foo", "bar"))

val matchers = profile.asMatchersList()
assertTrue(matchers.contains(profile.xmlViewIdsMatcher))

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

private fun PrivacyProfile.getPrivateSet(fieldName: String): Set<Any?> {
val field = PrivacyProfile::class.java.getDeclaredField(fieldName)
field.isAccessible = true
val value = field.get(this)
@Suppress("UNCHECKED_CAST")
return value as Set<Any?>
}

private class FakeMaskedView
}


Loading