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 3db8e0c90..bcf8ddde1 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 @@ -9,7 +9,9 @@ import com.launchdarkly.sdk.android.Components import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.LDConfig import com.launchdarkly.observability.plugin.Observability +import com.launchdarkly.observability.replay.PrivacyProfile import com.launchdarkly.observability.replay.ReplayInstrumentation +import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.sdk.android.LDAndroidLogging import com.launchdarkly.sdk.android.integrations.Plugin import io.opentelemetry.api.common.AttributeKey @@ -32,7 +34,11 @@ open class BaseApplication : Application() { logAdapter = LDAndroidLogging.adapter(), // TODO: consider these being factories so that the obs plugin can pass instantiation data, log adapter instrumentations = listOf( - ReplayInstrumentation() + ReplayInstrumentation( + options = ReplayOptions( + privacyProfile = PrivacyProfile(maskText = false) + ) + ) ), ) diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/SecondaryActivity.kt b/e2e/android/app/src/main/java/com/example/androidobservability/SecondaryActivity.kt index ad77220ec..f6a6b6482 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/SecondaryActivity.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/SecondaryActivity.kt @@ -4,13 +4,32 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.example.androidobservability.ui.theme.AndroidObservabilityTheme class SecondaryActivity : ComponentActivity() { @@ -19,10 +38,12 @@ class SecondaryActivity : ComponentActivity() { enableEdgeToEdge() setContent { AndroidObservabilityTheme { - Scaffold( modifier = Modifier.fillMaxSize() ) { innerPadding -> - Greeting2( - name = "Android", - modifier = Modifier.padding(innerPadding) + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + UserInfoForm( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) ) } } @@ -31,17 +52,163 @@ class SecondaryActivity : ComponentActivity() { } @Composable -fun Greeting2(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", +fun UserInfoForm(modifier: Modifier = Modifier) { + var password by remember { mutableStateOf("") } + var streetAddress by remember { mutableStateOf("") } + var city by remember { mutableStateOf("") } + var state by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var creditCardNumber by remember { mutableStateOf("") } + var expiryDate by remember { mutableStateOf("") } + var cvv by remember { mutableStateOf("") } + var cardholderName by remember { mutableStateOf("") } + + val scrollState = rememberScrollState() + + Column( modifier = modifier - ) + .verticalScroll(scrollState) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "User Information Form", + style = MaterialTheme.typography.headlineMedium + ) + + // Password Section + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Password", + style = MaterialTheme.typography.titleMedium + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth() + ) + } + } + + // Address Section + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Address Information", + style = MaterialTheme.typography.titleMedium + ) + OutlinedTextField( + value = streetAddress, + onValueChange = { streetAddress = it }, + label = { Text("Street Address") }, + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = city, + onValueChange = { city = it }, + label = { Text("City") }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = state, + onValueChange = { state = it }, + label = { Text("State") }, + modifier = Modifier.weight(1f) + ) + } + OutlinedTextField( + value = zipCode, + onValueChange = { zipCode = it }, + label = { Text("ZIP Code") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + } + } + + // Credit Card Section + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Credit Card Information", + style = MaterialTheme.typography.titleMedium + ) + OutlinedTextField( + value = cardholderName, + onValueChange = { cardholderName = it }, + label = { Text("Cardholder Name") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = creditCardNumber, + onValueChange = { creditCardNumber = it }, + label = { Text("Card Number") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = expiryDate, + onValueChange = { expiryDate = it }, + label = { Text("MM/YY") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = cvv, + onValueChange = { cvv = it }, + label = { Text("CVV") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.weight(1f) + ) + } + } + } + + // Submit Button + Button( + onClick = { /* Handle form submission */ }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Submit Information") + } + } } @Preview(showBackground = true) @Composable -fun GreetingPreview2() { +fun UserInfoFormPreview() { AndroidObservabilityTheme { - Greeting2("Android") + UserInfoForm() } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/CaptureSource.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/CaptureSource.kt index 167c69da6..765fb2931 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/CaptureSource.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/CaptureSource.kt @@ -12,14 +12,13 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.Base64 +import android.view.Choreographer import android.view.PixelCopy import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.getOrNull import io.opentelemetry.android.session.SessionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -42,9 +41,10 @@ import androidx.compose.ui.geometry.Rect as ComposeRect */ class CaptureSource( private val sessionManager: SessionManager, - private val privacyProfile: PrivacyProfile, + private val maskMatchers: List, // TODO: O11Y-628 - add captureQuality options -) : Application.ActivityLifecycleCallbacks { +) : + Application.ActivityLifecycleCallbacks { private var _activity: Activity? = null @@ -110,77 +110,79 @@ class CaptureSource( val activity = _activity ?: return@withContext null try { - val window = activity.window - val decorView = window.decorView - val decorViewWidth = decorView.width - val decorViewHeight = decorView.height + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val window = activity.window + val decorView = window.decorView + val decorViewWidth = decorView.width + val decorViewHeight = decorView.height - val rect = Rect(0, 0, decorViewWidth, decorViewHeight) + val rect = Rect(0, 0, decorViewWidth, decorViewHeight) - // protect against race condition where decor view has no size - if (decorViewWidth <= 0 || decorViewHeight <= 0) { - return@withContext null - } + // protect against race condition where decor view has no size + if (decorViewWidth <= 0 || decorViewHeight <= 0) { + return@withContext null + } - // TODO: O11Y-625 - optimize memory allocations - // TODO: O11Y-625 - see if holding bitmap is more efficient than base64 encoding immediately after compression - // TODO: O11Y-628 - use captureQuality option for scaling and adjust this bitmap accordingly, may need to investigate power of 2 rounding for performance - // Create a bitmap with the window dimensions - val bitmap = Bitmap.createBitmap(decorViewWidth, decorViewHeight, Bitmap.Config.ARGB_8888) + // TODO: O11Y-625 - optimize memory allocations + // TODO: O11Y-625 - see if holding bitmap is more efficient than base64 encoding immediately after compression + // TODO: O11Y-628 - use captureQuality option for scaling and adjust this bitmap accordingly, may need to investigate power of 2 rounding for performance + // Create a bitmap with the window dimensions + val bitmap = Bitmap.createBitmap(decorViewWidth, decorViewHeight, Bitmap.Config.ARGB_8888) - // Use PixelCopy to capture the window content - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { suspendCancellableCoroutine { continuation -> - // TODO: O11Y-624 - read PixelCopy exception recommendations and adjust logic to account for such cases - PixelCopy.request( - window, - rect, - bitmap, - { result -> - // record attributes immediately to provide accurate stamping - val timestamp = System.currentTimeMillis() - val session = sessionManager.getSessionId() - - if (result == PixelCopy.SUCCESS) { - // Offload heavy bitmap work to a background dispatcher - CoroutineScope(Dispatchers.Default).launch { - try { - val postMask = bitmap; - // TODO: O11Y-620 - masking -// val postMask: Bitmap = -// if (privacyProfile == PrivacyProfile.STRICT) { -// maskSensitiveAreas(bitmap, activity) -// } else { -// bitmap -// } - - // TODO: O11Y-625 - optimize memory allocations here, re-use byte arrays and such - val outputStream = ByteArrayOutputStream() - // TODO: O11Y-628 - calculate quality using captureQuality options - postMask.compress(Bitmap.CompressFormat.WEBP, 30, outputStream) - val byteArray = outputStream.toByteArray() - val compressedImage = - Base64.encodeToString(byteArray, Base64.NO_WRAP) - - val capture = Capture( - imageBase64 = compressedImage, - origWidth = decorViewWidth, - origHeight = decorViewHeight, - timestamp = timestamp, - session = session, - ) - continuation.resume(capture) - } catch (e: Exception) { - continuation.resumeWithException(e) + + // Synchronize with UI rendering frame + Choreographer.getInstance().postFrameCallback { + val sensitiveComposeRects = + findSensitiveComposeAreasFromActivity(activity, maskMatchers) + + // TODO: O11Y-624 - read PixelCopy exception recommendations and adjust logic to account for such cases + PixelCopy.request( + window, + rect, + bitmap, + { result -> + val timestamp = System.currentTimeMillis() + val session = sessionManager.getSessionId() + + if (result == PixelCopy.SUCCESS) { + CoroutineScope(Dispatchers.Default).launch { + try { + val postMask = if (maskMatchers.isNotEmpty()) { + maskSensitiveRects(bitmap, sensitiveComposeRects) + } else { + bitmap + } + + // TODO: O11Y-625 - optimize memory allocations here, re-use byte arrays and such + val outputStream = ByteArrayOutputStream() + // TODO: O11Y-628 - calculate quality using captureQuality options + postMask.compress(Bitmap.CompressFormat.WEBP, 30, outputStream) + val byteArray = outputStream.toByteArray() + val compressedImage = Base64.encodeToString(byteArray, Base64.NO_WRAP) + + val capture = Capture( + imageBase64 = compressedImage, + origWidth = decorViewWidth, + origHeight = decorViewHeight, + timestamp = timestamp, + session = session + ) + continuation.resume(capture) + } catch (e: Exception) { + continuation.resumeWithException(e) + } } + } else { + // TODO: O11Y-624 - implement handling/shutdown for errors and unsupported API levels + continuation.resumeWithException( + Exception("PixelCopy failed with result: $result") + ) } - } else { - // TODO: O11Y-624 - implement handling/shutdown for errors and unsupported API levels - continuation.resumeWithException(Exception("PixelCopy failed with result: $result")) - } - }, - Handler(Looper.getMainLooper()) // Handler for main thread - ) + }, + Handler(Looper.getMainLooper()) + ) + } } } else { // TODO: O11Y-624 - implement handling/shutdown for errors and unsupported API levels @@ -193,26 +195,25 @@ class CaptureSource( } /** - * Applies masking rectangles to the provided [bitmap] by inspecting the provided [activity] for - * content that needs to be masked. + * Applies masking rectangles to the provided [bitmap] using the provided [sensitiveRects]. * * @param bitmap The bitmap to mask - * @param activity The activity that the bitmap was captured from. + * @param sensitiveRects rects that will be masked */ - private fun maskSensitiveAreas(bitmap: Bitmap, activity: Activity): Bitmap { + private fun maskSensitiveRects(bitmap: Bitmap, sensitiveRects: List): Bitmap { + if (sensitiveRects.isEmpty()) { + return bitmap + } + // TODO: O11Y-625 - remove this bitmap copy if possible for memory optimization purposes val maskedBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true) val canvas = Canvas(maskedBitmap) val paint = Paint().apply { - color = Color.BLACK + color = Color.GRAY style = Paint.Style.FILL } - // Find sensitive areas using Compose semantics - val sensitiveComposeRects = findSensitiveComposeAreasFromActivity(activity) - - // Mask sensitive Compose areas found via semantics - sensitiveComposeRects.forEach { composeRect -> + sensitiveRects.forEach { composeRect -> val rect = Rect( composeRect.left.toInt(), composeRect.top.toInt(), @@ -230,7 +231,7 @@ class CaptureSource( * * @return a list of rects that represent sensitive areas that need to be masked */ - private fun findSensitiveComposeAreasFromActivity(activity: Activity): List { + private fun findSensitiveComposeAreasFromActivity(activity: Activity, matchers: List): List { val allSensitiveRects = mutableListOf() try { @@ -240,9 +241,9 @@ class CaptureSource( // Process each ComposeView to find sensitive areas composeViews.forEach { composeView -> val semanticsOwner = getSemanticsOwner(composeView) - val rootSemanticsNode = semanticsOwner?.rootSemanticsNode + val rootSemanticsNode = semanticsOwner?.unmergedRootSemanticsNode if (rootSemanticsNode != null) { - val sensitiveRects = findSensitiveComposeAreas(rootSemanticsNode, composeView) + val sensitiveRects = findSensitiveComposeAreas(rootSemanticsNode, composeView, matchers) allSensitiveRects.addAll(sensitiveRects) } } @@ -306,16 +307,16 @@ class CaptureSource( */ private fun findSensitiveComposeAreas( rootSemanticsNode: SemanticsNode, - composeView: ComposeView + composeView: ComposeView, + matchers: List ): List { val sensitiveRects = mutableListOf() try { // Recursively traverse the semantic node tree to find sensitive areas - traverseSemanticNode(rootSemanticsNode, sensitiveRects, composeView) + traverseSemanticNode(rootSemanticsNode, sensitiveRects, composeView, matchers) } catch (e: Exception) { - // Handle cases where semantic node traversal fails // This could happen if the semantic tree is not available or corrupted } @@ -328,35 +329,26 @@ class CaptureSource( private fun traverseSemanticNode( node: SemanticsNode, sensitiveRects: MutableList, - composeView: ComposeView + composeView: ComposeView, + matchers: List ) { - // Check if this node is marked as sensitive - if (isSensitiveNode(node)) { - // Convert bounds to absolute coordinates - val boundsInWindow = node.boundsInWindow - val absoluteRect = ComposeRect( - left = boundsInWindow.left, - top = boundsInWindow.top, - right = boundsInWindow.right, - bottom = boundsInWindow.bottom - ) - sensitiveRects.add(absoluteRect) + for (matcher in matchers) { + if (matcher.isMatch(node)) { + val boundsInWindow = node.boundsInWindow + val absoluteRect = ComposeRect( + left = boundsInWindow.left, + top = boundsInWindow.top, + right = boundsInWindow.right, + bottom = boundsInWindow.bottom + ) + sensitiveRects.add(absoluteRect) + break + } } // Recursively traverse all children node.children.forEach { child -> - traverseSemanticNode(child, sensitiveRects, composeView) + traverseSemanticNode(child, sensitiveRects, composeView, matchers) } } - - /** - * Check if a semantic node contains sensitive content based on test tags or content descriptions. - */ - private fun isSensitiveNode(node: SemanticsNode): Boolean { - // TODO: O11Y-620 - refactor to utilize generic MaskMatchers - - // Check for content description containing "sensitive" - val contentDescriptions = node.config.getOrNull(SemanticsProperties.ContentDescription) - return contentDescriptions?.any { it.contains("sensitive", ignoreCase = true) } == true - } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/MaskMatcher.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/MaskMatcher.kt new file mode 100644 index 000000000..d2c6e3e64 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/MaskMatcher.kt @@ -0,0 +1,20 @@ +package com.launchdarkly.observability.replay + +import androidx.compose.ui.semantics.SemanticsNode + +/** + * A [MaskMatcher] can determine if a [SemanticsNode] is a match and should be masked with an + * opaque mask in the session replay. + * + * Implement this interface and provide as part of a [PrivacyProfile] to customize masking behavior. + * + * Matchers should not do heavy work, should execute synchronously, and not dispatch to other + * threads for performance reasons. If you add a matcher and notice jitter, this may be + * the cause. + */ +interface MaskMatcher { + /** + * @return true if node is a match, false otherwise + */ + fun isMatch(node: SemanticsNode): Boolean +} 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 7f3391098..13bffa792 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,6 +1,130 @@ package com.launchdarkly.observability.replay -// TODO: O11Y-620 - implement full PrivacyProfiles and MaskingMatchers -enum class PrivacyProfile { - NO_MASK, STRICT +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull + +/** + * [PrivacyProfile] encapsulates options and functionality related to privacy of session + * replay functionality. + * + * By default, session replay will apply an opaque mask to text inputs, text, and sensitive views. + * See [sensitiveMatcher] for specific details. + * + * @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 [MaskMatcher]s that will be masked when they match + **/ +data class PrivacyProfile( + val maskTextInputs: Boolean = true, + val maskText: Boolean = true, + val maskSensitive: Boolean = true, + val maskAdditionalMatchers: List = emptyList(), +) { + + /** + * Converts this [PrivacyProfile] into its equivalent [MaskMatcher] list. + */ + internal fun asMatchersList(): List = buildList { + if (maskTextInputs) add(textInputMatcher) + if (maskText) add(textMatcher) + 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(node: SemanticsNode): Boolean { + val config = node.config + return config.contains(SemanticsProperties.EditableText) || + config.contains(SemanticsActions.SetText) || + config.contains(SemanticsActions.PasteText) || + config.contains(SemanticsActions.InsertTextAtCursor) + } + } + + /** + * 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(node: SemanticsNode): Boolean { + return node.config.contains(SemanticsProperties.Text) + } + } + + /** + * 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(node: SemanticsNode): Boolean {/**/ + if (node.config.contains(SemanticsProperties.Password)) { + return true + } + + // check text first for performance, more likely to get a match here than in description below + val textValues = node.config.getOrNull(SemanticsProperties.Text) + if (textValues != null) { + if (textValues.any { annotated -> + val lowerText = annotated.text.lowercase() + sensitiveKeywords.any { keyword -> + // could use ignoreCase = true here, but that is less + // performant than lower casing desc once above + lowerText.contains(keyword) + } + }) return true + } + + // check content description + val contentDescriptions = + node.config.getOrNull(SemanticsProperties.ContentDescription) + if (contentDescriptions != null) { + if (contentDescriptions.any { desc -> + val lowerDesc = desc.lowercase() + sensitiveKeywords.any { keyword -> + // could use ignoreCase = true here, but that is less + // performant than lower casing desc once above + lowerDesc.contains(keyword) + } + }) return true + } + + return false + } + } + + // 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/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt index 99473c29d..c1f62c520 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt @@ -78,8 +78,7 @@ class ReplayInstrumentation( override fun install(ctx: InstallationContext) { _otelLogger = ctx.openTelemetry.logsBridge.get(INSTRUMENTATION_SCOPE_NAME) - - _captureSource = CaptureSource(ctx.sessionManager, options.privacyProfile) + _captureSource = CaptureSource(ctx.sessionManager, options.privacyProfile.asMatchersList()) _captureSource.attachToApplication(ctx.application) // TODO: O11Y-621 - don't use global scope diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt index e1a552c10..f044ee626 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt @@ -16,7 +16,7 @@ data class ReplayOptions( val serviceVersion: String = BuildConfig.OBSERVABILITY_SDK_VERSION, val backendUrl: String = DEFAULT_BACKEND_URL, val debug: Boolean = false, - val privacyProfile: PrivacyProfile = PrivacyProfile.STRICT, + val privacyProfile: PrivacyProfile = PrivacyProfile(), val capturePeriodMillis: Long = 1000, // defaults to ever 1 second // TODO O11Y-623 - Add storage options )