diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle b/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle index 44ee55f2b8..8a9f632f86 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle @@ -83,7 +83,7 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.launchdarkly:launchdarkly-observability-android:0.42.0" + implementation "com.launchdarkly:launchdarkly-observability-android:0.45.0" implementation "com.launchdarkly:launchdarkly-android-client-sdk:5.12.0" // compileOnly: OTel Attributes appears in ObservabilityOptions parameter types; provided at // runtime transitively through launchdarkly-observability-android. diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt index 82792c9fad..3360f685f5 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt @@ -181,6 +181,12 @@ internal class SessionReplayClientAdapter private constructor() { return LDContext.createMulti(*contexts.toTypedArray()) } + /** + * Builds a [ReplayOptions] from the React Native bridge's options map. Returns defaults if + * the map is null. + * + * @param map options dictionary as received from JS, or `null` when no options were provided. + */ internal fun replayOptionsFrom(map: ReadableMap?): ReplayOptions { if (map == null) { return ReplayOptions( @@ -195,6 +201,9 @@ internal class SessionReplayClientAdapter private constructor() { val maskText = if (map.hasKey("maskLabels")) map.getBoolean("maskLabels") else false val maskImages = if (map.hasKey("maskImages")) map.getBoolean("maskImages") else false + val maskTestIDs = stringListFromMap(map, "maskTestIDs") + val unmaskTestIDs = stringListFromMap(map, "unmaskTestIDs") + return ReplayOptions( enabled = isEnabled, privacyProfile = PrivacyProfile( @@ -202,10 +211,30 @@ internal class SessionReplayClientAdapter private constructor() { maskWebViews = maskWebViews, maskText = maskText, maskImageViews = maskImages, + maskXMLViewIds = maskTestIDs, + unmaskXMLViewIds = unmaskTestIDs, ) ) } + /** + * Reads the value at [key] from [map] as a list of strings. Returns an empty list when the + * key is absent, the array is null, or any element is non-string. Non-string elements are + * dropped silently. + * + * @param map source ReadableMap. + * @param key key whose value should be a `ReadableArray` of strings. + */ + private fun stringListFromMap(map: ReadableMap, key: String): List { + if (!map.hasKey(key)) return emptyList() + val array = map.getArray(key) ?: return emptyList() + val out = ArrayList(array.size()) + for (i in 0 until array.size()) { + array.getString(i)?.let { out.add(it) } + } + return out + } + companion object { val shared = SessionReplayClientAdapter() private const val TAG = "LDSessionReplay" diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/test/java/com/sessionreplayreactnative/SessionReplayClientAdapterTest.kt b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/test/java/com/sessionreplayreactnative/SessionReplayClientAdapterTest.kt index 2c28ca9c2b..bf04967a07 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/test/java/com/sessionreplayreactnative/SessionReplayClientAdapterTest.kt +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/test/java/com/sessionreplayreactnative/SessionReplayClientAdapterTest.kt @@ -42,6 +42,8 @@ class SessionReplayClientAdapterTest { every { hasKey("maskTextInputs") } returns false every { hasKey("maskWebViews") } returns false every { hasKey("maskImages") } returns false + every { hasKey("maskTestIDs") } returns false + every { hasKey("unmaskTestIDs") } returns false } val options = adapter.replayOptionsFrom(map) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/babel.config.js b/sdk/@launchdarkly/react-native-ld-session-replay/example/babel.config.js index 486a09304d..b9aeb071c9 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/babel.config.js +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/babel.config.js @@ -7,6 +7,21 @@ const root = path.resolve(__dirname, '..'); module.exports = getConfig( { presets: ['module:@react-native/babel-preset'], + plugins: [ + // Loads values from example/.env at build time and inlines them at + // `process.env.X` references in app code. + [ + 'module:react-native-dotenv', + { + moduleName: '@env', + path: '.env', + // safe=false: don't enforce that .env keys match a .env.example schema. + safe: false, + // allowUndefined=true: missing keys resolve to undefined. + allowUndefined: true, + }, + ], + ], }, { root, pkg } ); diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/package.json b/sdk/@launchdarkly/react-native-ld-session-replay/example/package.json index 9903f0cbfd..d35505011b 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/package.json +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/package.json @@ -27,6 +27,7 @@ "@react-native/typescript-config": "0.83.0", "@types/react": "^19.2.0", "react-native-builder-bob": "^0.40.17", + "react-native-dotenv": "^3.4.11", "react-native-monorepo-config": "^0.3.1" }, "engines": { diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx index 835b6e0570..6e6b9eee57 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx @@ -1,12 +1,19 @@ -import { SafeAreaView } from 'react-native'; +import { + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; import { ReactNativeLDClient, LDProvider, AutoEnvAttributes, } from '@launchdarkly/react-native-client-sdk'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { createSessionReplayPlugin } from '@launchdarkly/session-replay-react-native'; import DialogsScreen from './DialogsScreen'; +import MaskingScreen from './MaskingScreen'; const plugin = createSessionReplayPlugin({ isEnabled: true, @@ -14,7 +21,8 @@ const plugin = createSessionReplayPlugin({ maskWebViews: true, maskLabels: true, maskImages: true, - maskAccessibilityIdentifiers: ['password', 'ssn'], + maskTestIDs: ['password', 'ssn'], + unmaskTestIDs: ['safe'], minimumAlpha: 0.05, }); @@ -28,7 +36,11 @@ const client = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, { }); const context = { kind: 'user', key: 'user-key-123abc' }; +type Tab = 'masking' | 'dialogs'; + export default function App() { + const [tab, setTab] = useState('masking'); + useEffect(() => { client.identify(context).catch((e: unknown) => console.log(e)); }, []); @@ -36,8 +48,65 @@ export default function App() { return ( - + + setTab('masking')} + /> + setTab('dialogs')} + /> + + {tab === 'masking' ? : } ); } + +function TabButton({ + label, + active, + onPress, +}: { + label: string; + active: boolean; + onPress: () => void; +}) { + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + tabBar: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + tab: { + flex: 1, + paddingVertical: 12, + alignItems: 'center', + }, + tabActive: { + borderBottomWidth: 2, + borderBottomColor: '#6650A4', + }, + tabText: { + color: '#fff', + fontWeight: '600', + }, +}); diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/MaskingScreen.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/MaskingScreen.tsx new file mode 100644 index 0000000000..737c7a097b --- /dev/null +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/MaskingScreen.tsx @@ -0,0 +1,118 @@ +import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'; + +/** + * Manual test screen for `maskTestIDs` / `unmaskTestIDs`. The plugin in `App.tsx` is configured + * with `maskTestIDs: ['password', 'ssn']` and `unmaskTestIDs: ['safe']`. Each row's testID is + * picked to exercise a specific case; the inline comment on each row explains the expected + * behavior under whatever values of `maskLabels` / `maskImages` are currently set in the plugin + * config. + * + * Section headers use `testID="safe"` so they remain readable in the recording regardless of + * `maskLabels`. + */ +export default function MaskingScreen() { + return ( + + + Some rows below will be masked in replays, depending on the plugin's + config. + + + + Text rows + + + {/* Always masked: testID is in maskTestIDs (explicit-mask wins regardless of + maskLabels). */} + + my password is hunter2 + + + {/* Always masked: testID is in maskTestIDs. */} + + ssn: 123-45-6789 + + + {/* Always unmasked: testID is in unmaskTestIDs (explicit-unmask overrides + maskLabels). */} + + safe text — should always be visible + + + {/* Masked iff maskLabels is on. testID does not match any list — falls through to + the global maskLabels rule. */} + + plain text with non-matching testID + + + + Image rows + + + {/* Always masked: testID is in maskTestIDs (explicit-mask wins regardless of + maskImages). */} + + + + testID="password" + + + + {/* Always unmasked: testID is in unmaskTestIDs (explicit-unmask overrides + maskImages). */} + + + + testID="safe" + + + + {/* Masked iff maskImages is on. testID does not match any list — falls through to + the global maskImages rule. */} + + + + testID="other" + + + + ); +} + +const LOGO = { uri: 'https://reactnative.dev/img/tiny_logo.png' }; + +const styles = StyleSheet.create({ + scroll: { + padding: 16, + gap: 12, + }, + intro: { + color: '#CAC4D0', + fontSize: 14, + fontStyle: 'italic', + }, + sectionHeader: { + color: '#fff', + fontSize: 20, + fontWeight: 'bold', + marginTop: 8, + }, + row: { + color: '#fff', + fontSize: 16, + paddingVertical: 6, + }, + imageRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + image: { + width: 64, + height: 64, + }, + imageLabel: { + color: '#fff', + fontSize: 16, + }, +}); diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift b/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift index 142308c835..6b282288f1 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift +++ b/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift @@ -187,18 +187,33 @@ extension SessionReplayClientAdapter { ) } + // testID-based mask/unmask accepts either the new `*TestIDs` keys or the deprecated + // `*AccessibilityIdentifiers` keys; if both are present, the lists are combined. + let maskTestIDs = + (dictionary["maskTestIDs"] as? [String] ?? []) + + (dictionary["maskAccessibilityIdentifiers"] as? [String] ?? []) + let unmaskTestIDs = + (dictionary["unmaskTestIDs"] as? [String] ?? []) + + (dictionary["unmaskAccessibilityIdentifiers"] as? [String] ?? []) + + // RN's renders to RCTTextView (Paper) or RCTParagraphComponentView (Fabric), neither + // of which extends UILabel — so the iOS SDK's `maskLabels` (which matches UILabel) doesn't + // catch RN text on its own. Add the RN text classes to `maskUIViews` when `maskLabels` is on. + let maskLabels = dictionary["maskLabels"] as? Bool ?? false + let maskUIViews: [AnyClass] = maskLabels + ? ["RCTTextView", "RCTParagraphComponentView"].compactMap { NSClassFromString($0) } + : [] + let privacy = SessionReplayOptions.PrivacyOptions( maskTextInputs: dictionary["maskTextInputs"] as? Bool ?? true, maskWebViews: dictionary["maskWebViews"] as? Bool ?? false, - maskLabels: dictionary["maskLabels"] as? Bool ?? false, + maskLabels: maskLabels, maskImages: dictionary["maskImages"] as? Bool ?? false, - maskUIViews: [], /// Not supported, since AnyClass has type erased and it is very likely is not serializable + maskUIViews: maskUIViews, unmaskUIViews: [], /// Not supported, since AnyClass has type erased and it is very likely is not serializable ignoreUIViews: [], /// Not supported, since AnyClass has type erased and it is very likely is not serializable - maskAccessibilityIdentifiers: - dictionary["maskAccessibilityIdentifiers"] as? [String] ?? [], - unmaskAccessibilityIdentifiers: - dictionary["unmaskAccessibilityIdentifiers"] as? [String] ?? [], + maskAccessibilityIdentifiers: maskTestIDs, + unmaskAccessibilityIdentifiers: unmaskTestIDs, ignoreAccessibilityIdentifiers: dictionary["ignoreAccessibilityIdentifiers"] as? [String] ?? [], minimumAlpha: diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts index 2b5898fbe0..ed54b1225e 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts @@ -7,8 +7,28 @@ export type SessionReplayOptions = { maskWebViews?: boolean; maskLabels?: boolean; maskImages?: boolean; + + /** + * Mask views whose `testID` prop is in this list. Match is exact string equality. + */ + maskTestIDs?: string[]; + + /** + * Override masking for views whose `testID` prop is in this list. Takes precedence + * over global masking rules. Match is exact string equality. + */ + unmaskTestIDs?: string[]; + + /** + * @deprecated Use `maskTestIDs` instead. + */ maskAccessibilityIdentifiers?: string[]; + + /** + * @deprecated Use `unmaskTestIDs` instead. + */ unmaskAccessibilityIdentifiers?: string[]; + ignoreAccessibilityIdentifiers?: string[]; minimumAlpha?: number; }; diff --git a/yarn.lock b/yarn.lock index 3df96b5b71..3db2f8f946 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41134,6 +41134,17 @@ __metadata: languageName: node linkType: hard +"react-native-dotenv@npm:^3.4.11": + version: 3.4.11 + resolution: "react-native-dotenv@npm:3.4.11" + dependencies: + dotenv: "npm:^16.4.5" + peerDependencies: + "@babel/runtime": ^7.20.6 + checksum: 10/09e8a7310fcb01ac021e71db9328e9d342d1e117bf68026b12de0392bfe17292ac6a071f03b88e7fb42c82a8f2fdf03bc520c7dedd2f80a1448cb3de5e03d4fb + languageName: node + linkType: hard + "react-native-edge-to-edge@npm:1.6.0": version: 1.6.0 resolution: "react-native-edge-to-edge@npm:1.6.0" @@ -43466,6 +43477,7 @@ __metadata: react: "npm:19.2.0" react-native: "npm:0.83.0" react-native-builder-bob: "npm:^0.40.17" + react-native-dotenv: "npm:^3.4.11" react-native-monorepo-config: "npm:^0.3.1" languageName: unknown linkType: soft