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
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -195,17 +201,40 @@ 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")
Comment thread
beekld marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

return ReplayOptions(
enabled = isEnabled,
privacyProfile = PrivacyProfile(
maskTextInputs = maskTextInputs,
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<String> {
if (!map.hasKey(key)) return emptyList()
val array = map.getArray(key) ?: return emptyList()
val out = ArrayList<String>(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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
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,
maskTextInputs: true,
maskWebViews: true,
maskLabels: true,
maskImages: true,
maskAccessibilityIdentifiers: ['password', 'ssn'],
maskTestIDs: ['password', 'ssn'],
unmaskTestIDs: ['safe'],
minimumAlpha: 0.05,
});

Expand All @@ -28,16 +36,77 @@ 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<Tab>('masking');

useEffect(() => {
client.identify(context).catch((e: unknown) => console.log(e));
}, []);

return (
<LDProvider client={client}>
<SafeAreaView style={{ flex: 1, backgroundColor: '#000' }}>
<DialogsScreen />
<View style={styles.tabBar}>
<TabButton
label="Masking"
active={tab === 'masking'}
onPress={() => setTab('masking')}
/>
<TabButton
label="Dialogs"
active={tab === 'dialogs'}
onPress={() => setTab('dialogs')}
/>
</View>
{tab === 'masking' ? <MaskingScreen /> : <DialogsScreen />}
</SafeAreaView>
</LDProvider>
);
}

function TabButton({
label,
active,
onPress,
}: {
label: string;
active: boolean;
onPress: () => void;
}) {
return (
<TouchableOpacity
// testID="safe" so the tab labels stay visible regardless of maskLabels —
// they're navigation chrome, not content under test.
testID="safe"
style={[styles.tab, active ? styles.tabActive : undefined]}
onPress={onPress}
>
<Text testID="safe" style={styles.tabText}>
{label}
</Text>
</TouchableOpacity>
);
}

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',
},
});
Original file line number Diff line number Diff line change
@@ -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 (
<ScrollView contentContainerStyle={styles.scroll}>
<Text testID="safe" style={styles.intro}>
Some rows below will be masked in replays, depending on the plugin's
config.
</Text>

<Text testID="safe" style={styles.sectionHeader}>
Text rows
</Text>

{/* Always masked: testID is in maskTestIDs (explicit-mask wins regardless of
maskLabels). */}
<Text testID="password" style={styles.row}>
my password is hunter2
</Text>

{/* Always masked: testID is in maskTestIDs. */}
<Text testID="ssn" style={styles.row}>
ssn: 123-45-6789
</Text>

{/* Always unmasked: testID is in unmaskTestIDs (explicit-unmask overrides
maskLabels). */}
<Text testID="safe" style={styles.row}>
safe text — should always be visible
</Text>

{/* Masked iff maskLabels is on. testID does not match any list — falls through to
the global maskLabels rule. */}
<Text testID="other" style={styles.row}>
plain text with non-matching testID
</Text>

<Text testID="safe" style={styles.sectionHeader}>
Image rows
</Text>

{/* Always masked: testID is in maskTestIDs (explicit-mask wins regardless of
maskImages). */}
<View style={styles.imageRow}>
<Image testID="password" source={LOGO} style={styles.image} />
<Text testID="safe" style={styles.imageLabel}>
testID="password"
</Text>
</View>

{/* Always unmasked: testID is in unmaskTestIDs (explicit-unmask overrides
maskImages). */}
<View style={styles.imageRow}>
<Image testID="safe" source={LOGO} style={styles.image} />
<Text testID="safe" style={styles.imageLabel}>
testID="safe"
</Text>
</View>

{/* Masked iff maskImages is on. testID does not match any list — falls through to
the global maskImages rule. */}
<View style={styles.imageRow}>
<Image testID="other" source={LOGO} style={styles.image} />
<Text testID="safe" style={styles.imageLabel}>
testID="other"
</Text>
</View>
</ScrollView>
);
}

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,
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Text> 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:
Expand Down
Loading
Loading