diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt index 80a97f875..f7a4aedd6 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt @@ -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 @@ -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")) ) ) diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/smoothie/SmoothieAdapter.kt b/e2e/android/app/src/main/java/com/example/androidobservability/smoothie/SmoothieAdapter.kt index b5d415f78..c8f0f6328 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/smoothie/SmoothieAdapter.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/smoothie/SmoothieAdapter.kt @@ -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) } diff --git a/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt b/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt index 5b32dbb43..e02f986a1 100644 --- a/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt +++ b/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt @@ -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 @@ -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() diff --git a/sdk/@launchdarkly/observability-android/README.md b/sdk/@launchdarkly/observability-android/README.md index fc55ef7c1..3fffb9e73 100644 --- a/sdk/@launchdarkly/observability-android/README.md +++ b/sdk/@launchdarkly/observability-android/README.md @@ -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") @@ -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`). diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/MaskViewRef.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/MaskViewRef.kt new file mode 100644 index 000000000..94c2f0bf0 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/MaskViewRef.kt @@ -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) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/PrivacyProfile.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/PrivacyProfile.kt index 1f7b4fe4c..174ddcebc 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/PrivacyProfile.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/PrivacyProfile.kt @@ -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 = emptyList(), + val maskXMLViewIds: List = emptyList(), val maskAdditionalMatchers: List = 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 = 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", + ) } + diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/PrivacyProfileTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/PrivacyProfileTest.kt new file mode 100644 index 000000000..30494d55c --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/PrivacyProfileTest.kt @@ -0,0 +1,102 @@ +package com.launchdarkly.observability.replay + +import android.widget.ImageView +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +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 `maskImageViews defaults to false`() { + val profile = PrivacyProfile() + assertFalse(profile.maskImageViews) + } + + @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 and @id prefixes and adds xmlViewIdsMatcher to matchers list`() { + val profile = PrivacyProfile(maskXMLViewIds = listOf("@+id/foo", "@id/baz", "bar")) + + val matchers = profile.asMatchersList() + assertTrue(matchers.contains(profile.xmlViewIdsMatcher)) + + val idSet = profile.getPrivateSet("maskXMLViewIdSet") + assertTrue(idSet.contains("foo")) + assertTrue(idSet.contains("baz")) + assertTrue(idSet.contains("bar")) + assertFalse(idSet.contains("@+id/foo")) + assertFalse(idSet.contains("@id/baz")) + } + + @Test + fun `maskImageViews adds ImageView to viewClassSet and includes viewsMatcher even when maskViews is empty`() { + val profile = PrivacyProfile(maskImageViews = true, maskViews = emptyList()) + + val matchers = profile.asMatchersList() + assertTrue(matchers.contains(profile.viewsMatcher)) + + val viewClassSet = profile.getPrivateSet("viewClassSet") + assertTrue(viewClassSet.contains(ImageView::class.java)) + } + + @Test + fun `maskImageViews false does not add ImageView to viewClassSet and does not include viewsMatcher when maskViews is empty`() { + val profile = PrivacyProfile(maskImageViews = false, maskViews = emptyList()) + + val matchers = profile.asMatchersList() + assertFalse(matchers.contains(profile.viewsMatcher)) + + val viewClassSet = profile.getPrivateSet("viewClassSet") + assertFalse(viewClassSet.contains(ImageView::class.java)) + } + + @Test + fun `invalid string-based maskViews class name throws targeted error`() { + val fqcn = "com.example.this.does.not.ExistView" + val ex = assertThrows(IllegalArgumentException::class.java) { + PrivacyProfile(maskViews = listOf(view(fqcn))) + } + + val message = ex.message ?: "" + assertTrue(message.contains("PrivacyProfile.maskViews")) + assertTrue(message.contains(fqcn)) + assertTrue(ex.cause is ClassNotFoundException) + } + + private fun PrivacyProfile.getPrivateSet(fieldName: String): Set { + val field = PrivacyProfile::class.java.getDeclaredField(fieldName) + field.isAccessible = true + val value = field.get(this) + @Suppress("UNCHECKED_CAST") + return value as Set + } + + private class FakeMaskedView +} + +