@repliqo/sdk-react-native 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1382,3 +1382,76 @@ the user naturally scrolls, exactly like the ScrollView variant.
1382
1382
  > `@react-native/metro-config` for RN 0.72+ and in Expo). On older RN
1383
1383
  > versions either install AsyncStorage, pass your own `storage`, or enable
1384
1384
  > that Metro option.
1385
+
1386
+ ## 21. Privacy: Masking Sensitive Content
1387
+
1388
+ Session replay screenshots and heatmap tiles are real pixels from the
1389
+ user's screen. Anything visible is captured — including emails, names,
1390
+ balances and card numbers. Repliqo redacts sensitive regions **on the
1391
+ device, before the image is encoded**, so those pixels never reach the
1392
+ network or your storage.
1393
+
1394
+ ### Automatic
1395
+
1396
+ Password inputs are always redacted, no configuration needed:
1397
+
1398
+ ```tsx
1399
+ <TextInput secureTextEntry value={password} onChangeText={setPassword} />
1400
+ ```
1401
+
1402
+ Any field using a password transformation is detected natively and covered
1403
+ with an opaque block in every capture.
1404
+
1405
+ ### Manual
1406
+
1407
+ Wrap anything else you don't want captured:
1408
+
1409
+ ```tsx
1410
+ import { RepliqoMask } from '@repliqo/sdk-react-native';
1411
+
1412
+ <RepliqoMask>
1413
+ <Text>{user.email}</Text>
1414
+ <Text>{account.balance}</Text>
1415
+ </RepliqoMask>
1416
+ ```
1417
+
1418
+ The content renders normally on the device — only the captured image is
1419
+ masked. Masking applies to BOTH session replay snapshots and the scroll
1420
+ tiles used to build heatmap backgrounds.
1421
+
1422
+ Temporarily disable redaction for a subtree (e.g. behind a "show details"
1423
+ toggle you deliberately want in the replay):
1424
+
1425
+ ```tsx
1426
+ <RepliqoMask enabled={!hideSensitive}>
1427
+ <OrderSummary />
1428
+ </RepliqoMask>
1429
+ ```
1430
+
1431
+ `RepliqoMask` accepts all `View` props, so it can replace a wrapper you
1432
+ already have. A `nativeID` you pass is preserved.
1433
+
1434
+ ### What to mask
1435
+
1436
+ At minimum, wrap anything that would be a compliance problem in a
1437
+ screenshot: payment fields, government/ID numbers, health information,
1438
+ full names and emails, auth tokens shown on screen, and any PII your
1439
+ privacy policy commits to protecting.
1440
+
1441
+ > Masking covers the region occupied by the wrapped views. If a sensitive
1442
+ > value is rendered by a native module that draws outside its own view
1443
+ > bounds (rare), verify the result in a real replay before shipping.
1444
+
1445
+ ### Platform behavior (important)
1446
+
1447
+ Redaction is performed by the **native Android capture path**, which paints
1448
+ over the sensitive regions before the JPEG is encoded.
1449
+
1450
+ When that path is unavailable — on iOS, or on Android if a frame can only be
1451
+ produced by the `react-native-view-shot` fallback — the SDK **fails closed**:
1452
+ while any `<RepliqoMask>` is mounted, those frames are **skipped entirely**
1453
+ rather than captured without redaction. You may see gaps in a replay on
1454
+ those platforms; that is deliberate. Losing a frame is preferable to
1455
+ uploading content you marked as sensitive.
1456
+
1457
+ Heatmap scroll tiles always use the native path, so they are always redacted.
@@ -0,0 +1,159 @@
1
+ package com.repliqo.screencapture;
2
+
3
+ import android.graphics.Rect;
4
+ import android.text.method.PasswordTransformationMethod;
5
+ import android.util.Log;
6
+ import android.view.View;
7
+ import android.view.ViewGroup;
8
+ import android.widget.TextView;
9
+
10
+ import java.util.ArrayList;
11
+ import java.util.List;
12
+
13
+ /**
14
+ * Finds the on-screen regions that must be redacted before a capture leaves
15
+ * the device.
16
+ *
17
+ * A region is masked when ANY of these hold:
18
+ *
19
+ * 1. The view is tagged with the Repliqo mask marker — set by the
20
+ * {@code <RepliqoMask>} JS component through React Native's
21
+ * {@code nativeID} prop (and, as a fallback, {@code testID} or the
22
+ * accessibility label).
23
+ * 2. The view is a password field (any TextView/EditText using a
24
+ * password transformation). These are masked AUTOMATICALLY: a
25
+ * session-replay screenshot must never contain a typed password,
26
+ * even if the host app forgot to wrap it.
27
+ *
28
+ * All tag lookups are defensive — React Native's internal resource id is
29
+ * resolved by name at runtime, so this keeps working across RN versions
30
+ * and degrades to the fallbacks (never throws) if it can't be resolved.
31
+ */
32
+ final class MaskCollector {
33
+ private static final String TAG = "RepliqoCapture";
34
+
35
+ /** Marker value set by the RepliqoMask component. */
36
+ static final String MASK_MARKER = "repliqo-mask";
37
+
38
+ /** Guard against pathological / cyclic view trees. */
39
+ private static final int MAX_DEPTH = 60;
40
+
41
+ /**
42
+ * Cached resolution of React Native's `view_tag_native_id` resource id.
43
+ * 0 = not resolved yet, -1 = resolution failed (don't retry).
44
+ */
45
+ private static int nativeIdTagKey = 0;
46
+
47
+ private MaskCollector() {}
48
+
49
+ /**
50
+ * Collect the rectangles to redact, in coordinates RELATIVE to
51
+ * {@code root} (so they can be painted with the same canvas transform
52
+ * used to draw that root view).
53
+ */
54
+ static List<Rect> collect(View root) {
55
+ List<Rect> rects = new ArrayList<>();
56
+ try {
57
+ int[] rootLoc = new int[2];
58
+ root.getLocationInWindow(rootLoc);
59
+ walk(root, rootLoc, rects, 0);
60
+ } catch (Exception e) {
61
+ // Never let mask collection break a capture — but a failure here
62
+ // means unredacted output, so it must be visible in logs.
63
+ Log.w(TAG, "Mask collection failed: " + e.getMessage());
64
+ }
65
+ return rects;
66
+ }
67
+
68
+ private static void walk(View view, int[] rootLoc, List<Rect> out, int depth) {
69
+ if (view == null || depth > MAX_DEPTH) return;
70
+ if (view.getVisibility() != View.VISIBLE) return;
71
+ if (view.getWidth() <= 0 || view.getHeight() <= 0) return;
72
+
73
+ if (isSensitive(view)) {
74
+ int[] loc = new int[2];
75
+ view.getLocationInWindow(loc);
76
+ int left = loc[0] - rootLoc[0];
77
+ int top = loc[1] - rootLoc[1];
78
+ out.add(new Rect(left, top, left + view.getWidth(), top + view.getHeight()));
79
+ // No need to descend: the whole subtree is covered by this rect.
80
+ return;
81
+ }
82
+
83
+ if (view instanceof ViewGroup) {
84
+ ViewGroup group = (ViewGroup) view;
85
+ int count = group.getChildCount();
86
+ for (int i = 0; i < count; i++) {
87
+ walk(group.getChildAt(i), rootLoc, out, depth + 1);
88
+ }
89
+ }
90
+ }
91
+
92
+ private static boolean isSensitive(View view) {
93
+ return isPasswordField(view) || hasMaskMarker(view);
94
+ }
95
+
96
+ /**
97
+ * Password fields are redacted automatically — no host-app opt-in.
98
+ */
99
+ private static boolean isPasswordField(View view) {
100
+ try {
101
+ if (!(view instanceof TextView)) return false;
102
+ TextView tv = (TextView) view;
103
+ return tv.getTransformationMethod() instanceof PasswordTransformationMethod;
104
+ } catch (Exception e) {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ private static boolean hasMaskMarker(View view) {
110
+ // 1. React Native `nativeID` — stored under RN's own tag key.
111
+ try {
112
+ int key = resolveNativeIdTagKey(view);
113
+ if (key > 0) {
114
+ Object tag = view.getTag(key);
115
+ if (matches(tag)) return true;
116
+ }
117
+ } catch (Exception ignored) {
118
+ // fall through to the generic fallbacks
119
+ }
120
+
121
+ // 2. Plain view tag (RN `testID` on several versions).
122
+ try {
123
+ if (matches(view.getTag())) return true;
124
+ } catch (Exception ignored) {
125
+ // fall through
126
+ }
127
+
128
+ // 3. Accessibility label — last-resort marker.
129
+ try {
130
+ CharSequence desc = view.getContentDescription();
131
+ if (desc != null && matches(desc.toString())) return true;
132
+ } catch (Exception ignored) {
133
+ // fall through
134
+ }
135
+
136
+ return false;
137
+ }
138
+
139
+ private static boolean matches(Object tag) {
140
+ return tag instanceof String && ((String) tag).startsWith(MASK_MARKER);
141
+ }
142
+
143
+ /**
144
+ * Resolve React Native's internal `view_tag_native_id` resource id by
145
+ * NAME rather than linking against com.facebook.react.R, so this works
146
+ * regardless of the RN version the host app builds with.
147
+ */
148
+ private static int resolveNativeIdTagKey(View view) {
149
+ if (nativeIdTagKey != 0) return nativeIdTagKey;
150
+ try {
151
+ int id = view.getResources().getIdentifier(
152
+ "view_tag_native_id", "id", view.getContext().getPackageName());
153
+ nativeIdTagKey = id > 0 ? id : -1;
154
+ } catch (Exception e) {
155
+ nativeIdTagKey = -1;
156
+ }
157
+ return nativeIdTagKey;
158
+ }
159
+ }
@@ -3,6 +3,8 @@ package com.repliqo.screencapture;
3
3
  import android.app.Activity;
4
4
  import android.graphics.Bitmap;
5
5
  import android.graphics.Canvas;
6
+ import android.graphics.Color;
7
+ import android.graphics.Paint;
6
8
  import android.graphics.Rect;
7
9
  import android.os.Build;
8
10
  import android.util.Base64;
@@ -37,6 +39,25 @@ public class MultiWindowCapture {
37
39
  private static final int TARGET_WIDTH = 390;
38
40
  private static final int JPEG_QUALITY = 40;
39
41
 
42
+ /**
43
+ * Redaction blocks. Opaque fill so nothing shows through JPEG
44
+ * compression, plus a visible outline so a reviewer can tell the area
45
+ * was intentionally masked rather than blank/broken.
46
+ */
47
+ private static final Paint MASK_FILL_PAINT = new Paint();
48
+ private static final Paint MASK_STROKE_PAINT = new Paint();
49
+
50
+ static {
51
+ MASK_FILL_PAINT.setColor(Color.rgb(30, 34, 60));
52
+ MASK_FILL_PAINT.setStyle(Paint.Style.FILL);
53
+ MASK_FILL_PAINT.setAntiAlias(false);
54
+
55
+ MASK_STROKE_PAINT.setColor(Color.rgb(99, 102, 241));
56
+ MASK_STROKE_PAINT.setStyle(Paint.Style.STROKE);
57
+ MASK_STROKE_PAINT.setStrokeWidth(2f);
58
+ MASK_STROKE_PAINT.setAntiAlias(false);
59
+ }
60
+
40
61
  /**
41
62
  * Capture all visible windows composited into a single JPEG.
42
63
  *
@@ -105,6 +126,11 @@ public class MultiWindowCapture {
105
126
  Log.w(TAG, "Failed to draw view: " + e.getMessage());
106
127
  }
107
128
 
129
+ // Redact sensitive regions IN THE SAME transform, right
130
+ // after drawing this window — password fields and views
131
+ // wrapped in <RepliqoMask> must never reach the network.
132
+ drawMasks(canvas, info.view);
133
+
108
134
  canvas.restore();
109
135
  }
110
136
 
@@ -123,6 +149,25 @@ public class MultiWindowCapture {
123
149
  }
124
150
  }
125
151
 
152
+ /**
153
+ * Paint opaque blocks over every region that must not leave the device
154
+ * (password fields, views wrapped in RepliqoMask). Coordinates come back
155
+ * relative to {@code root}, so this must be called with the same canvas
156
+ * transform used to draw it.
157
+ */
158
+ private static void drawMasks(Canvas canvas, View root) {
159
+ try {
160
+ List<Rect> rects = MaskCollector.collect(root);
161
+ if (rects.isEmpty()) return;
162
+ for (Rect r : rects) {
163
+ canvas.drawRect(r, MASK_FILL_PAINT);
164
+ canvas.drawRect(r, MASK_STROKE_PAINT);
165
+ }
166
+ } catch (Exception e) {
167
+ Log.w(TAG, "Failed to draw masks: " + e.getMessage());
168
+ }
169
+ }
170
+
126
171
  /**
127
172
  * Fallback: capture just the activity's decor view.
128
173
  */
@@ -135,6 +180,7 @@ public class MultiWindowCapture {
135
180
  Canvas canvas = new Canvas(bitmap);
136
181
  canvas.scale(scale, scale);
137
182
  decorView.draw(canvas);
183
+ drawMasks(canvas, decorView);
138
184
 
139
185
  String base64 = bitmapToBase64(bitmap);
140
186
  if (base64 == null) return null;
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+ import { type ViewProps } from 'react-native';
3
+ /**
4
+ * Marker value the native capture layer looks for. Must stay in sync with
5
+ * MaskCollector.MASK_MARKER on the Android side.
6
+ */
7
+ export declare const REPLIQO_MASK_MARKER = "repliqo-mask";
8
+ export interface RepliqoMaskProps extends ViewProps {
9
+ /**
10
+ * Set to false to temporarily disable redaction for this subtree
11
+ * (e.g. behind a "show password" toggle you explicitly want captured).
12
+ * Defaults to true.
13
+ */
14
+ enabled?: boolean;
15
+ children?: React.ReactNode;
16
+ }
17
+ /**
18
+ * Redacts everything inside it from session-replay screenshots and heatmap
19
+ * tiles. The content still renders normally on the device — only the
20
+ * captured image is masked, and the masking happens ON-DEVICE, before the
21
+ * image is encoded, so sensitive pixels never reach the network.
22
+ *
23
+ * ```tsx
24
+ * <RepliqoMask>
25
+ * <Text>{user.email}</Text>
26
+ * <CreditCardForm />
27
+ * </RepliqoMask>
28
+ * ```
29
+ *
30
+ * Password inputs (`<TextInput secureTextEntry>`) are redacted
31
+ * AUTOMATICALLY and do not need to be wrapped.
32
+ *
33
+ * Implementation note: the marker travels through the `nativeID` prop,
34
+ * which React Native forwards to the underlying Android view. Any
35
+ * `nativeID` you pass yourself is preserved (appended after the marker) so
36
+ * existing view lookups keep working.
37
+ */
38
+ export declare function RepliqoMask({ enabled, children, nativeID, ...props }: RepliqoMaskProps): React.JSX.Element;
39
+ export declare namespace RepliqoMask {
40
+ var displayName: string;
41
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.REPLIQO_MASK_MARKER = void 0;
37
+ exports.RepliqoMask = RepliqoMask;
38
+ const react_1 = __importStar(require("react"));
39
+ const react_native_1 = require("react-native");
40
+ const maskRegistry_1 = require("../privacy/maskRegistry");
41
+ /**
42
+ * Marker value the native capture layer looks for. Must stay in sync with
43
+ * MaskCollector.MASK_MARKER on the Android side.
44
+ */
45
+ exports.REPLIQO_MASK_MARKER = 'repliqo-mask';
46
+ /**
47
+ * Redacts everything inside it from session-replay screenshots and heatmap
48
+ * tiles. The content still renders normally on the device — only the
49
+ * captured image is masked, and the masking happens ON-DEVICE, before the
50
+ * image is encoded, so sensitive pixels never reach the network.
51
+ *
52
+ * ```tsx
53
+ * <RepliqoMask>
54
+ * <Text>{user.email}</Text>
55
+ * <CreditCardForm />
56
+ * </RepliqoMask>
57
+ * ```
58
+ *
59
+ * Password inputs (`<TextInput secureTextEntry>`) are redacted
60
+ * AUTOMATICALLY and do not need to be wrapped.
61
+ *
62
+ * Implementation note: the marker travels through the `nativeID` prop,
63
+ * which React Native forwards to the underlying Android view. Any
64
+ * `nativeID` you pass yourself is preserved (appended after the marker) so
65
+ * existing view lookups keep working.
66
+ */
67
+ function RepliqoMask({ enabled = true, children, nativeID, ...props }) {
68
+ // Announce this region while it's mounted. If a capture can only be
69
+ // produced by the fallback path (which cannot redact), the SDK skips
70
+ // that frame instead of shipping the content this mask protects.
71
+ (0, react_1.useEffect)(() => {
72
+ if (!enabled)
73
+ return;
74
+ (0, maskRegistry_1.registerMask)();
75
+ return () => (0, maskRegistry_1.unregisterMask)();
76
+ }, [enabled]);
77
+ const markerId = enabled
78
+ ? nativeID
79
+ ? `${exports.REPLIQO_MASK_MARKER}:${nativeID}`
80
+ : exports.REPLIQO_MASK_MARKER
81
+ : nativeID;
82
+ return (<react_native_1.View {...props} nativeID={markerId} collapsable={false}>
83
+ {children}
84
+ </react_native_1.View>);
85
+ }
86
+ RepliqoMask.displayName = 'RepliqoMask';
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export type { SnapshotCaptureConfig } from './snapshot/capture';
13
13
  export type { ScreenshotResult } from './snapshot/captureScreenshot';
14
14
  export { RepliqoScrollView } from './components/RepliqoScrollView';
15
15
  export { RepliqoFlatList } from './components/RepliqoFlatList';
16
+ export { RepliqoMask, REPLIQO_MASK_MARKER } from './components/RepliqoMask';
17
+ export type { RepliqoMaskProps } from './components/RepliqoMask';
16
18
  export type { PersistentStorage } from './core/storage';
17
19
  export { DEFAULT_CONFIG } from './core/config';
18
20
  export type { ResolvedConfig } from './core/config';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_CONFIG = exports.RepliqoFlatList = exports.RepliqoScrollView = exports.captureNativeFrame = exports.isNativeScreenCaptureAvailable = exports.captureScreenshot = exports.SnapshotCapture = exports.ErrorTracker = exports.trackScreenExit = exports.trackScreenEnter = exports.trackScreenChange = exports.useNavigationTracker = exports.TouchTracker = exports.AppAnalytics = void 0;
3
+ exports.DEFAULT_CONFIG = exports.REPLIQO_MASK_MARKER = exports.RepliqoMask = exports.RepliqoFlatList = exports.RepliqoScrollView = exports.captureNativeFrame = exports.isNativeScreenCaptureAvailable = exports.captureScreenshot = exports.SnapshotCapture = exports.ErrorTracker = exports.trackScreenExit = exports.trackScreenEnter = exports.trackScreenChange = exports.useNavigationTracker = exports.TouchTracker = exports.AppAnalytics = void 0;
4
4
  // Main class
5
5
  var client_1 = require("./core/client");
6
6
  Object.defineProperty(exports, "AppAnalytics", { enumerable: true, get: function () { return client_1.AppAnalytics; } });
@@ -28,6 +28,9 @@ var RepliqoScrollView_1 = require("./components/RepliqoScrollView");
28
28
  Object.defineProperty(exports, "RepliqoScrollView", { enumerable: true, get: function () { return RepliqoScrollView_1.RepliqoScrollView; } });
29
29
  var RepliqoFlatList_1 = require("./components/RepliqoFlatList");
30
30
  Object.defineProperty(exports, "RepliqoFlatList", { enumerable: true, get: function () { return RepliqoFlatList_1.RepliqoFlatList; } });
31
+ var RepliqoMask_1 = require("./components/RepliqoMask");
32
+ Object.defineProperty(exports, "RepliqoMask", { enumerable: true, get: function () { return RepliqoMask_1.RepliqoMask; } });
33
+ Object.defineProperty(exports, "REPLIQO_MASK_MARKER", { enumerable: true, get: function () { return RepliqoMask_1.REPLIQO_MASK_MARKER; } });
31
34
  // Config
32
35
  var config_1 = require("./core/config");
33
36
  Object.defineProperty(exports, "DEFAULT_CONFIG", { enumerable: true, get: function () { return config_1.DEFAULT_CONFIG; } });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tracks how many <RepliqoMask> regions are currently mounted.
3
+ *
4
+ * Redaction is implemented in the native capture path (it paints over the
5
+ * sensitive regions before the JPEG is encoded). The react-native-view-shot
6
+ * fallback has no such step, so a frame produced by it would contain the
7
+ * unredacted pixels.
8
+ *
9
+ * This registry lets the capture layer FAIL CLOSED: when the host app has
10
+ * asked for masking but only the unredacted path is available, the frame is
11
+ * skipped instead of uploaded. Losing a replay frame is always preferable to
12
+ * leaking the content the developer explicitly marked as sensitive.
13
+ *
14
+ * Lives in its own module so both the component and the capture function can
15
+ * use it without importing the SDK client (which would be circular).
16
+ */
17
+ /** Called by RepliqoMask on mount (when enabled). */
18
+ export declare function registerMask(): void;
19
+ /** Called by RepliqoMask on unmount (or when disabled). */
20
+ export declare function unregisterMask(): void;
21
+ /** True when at least one mask region is currently mounted. */
22
+ export declare function hasActiveMasks(): boolean;
23
+ /** Test-only: reset the counter between cases. */
24
+ export declare function __resetMaskRegistryForTests(): void;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ /**
3
+ * Tracks how many <RepliqoMask> regions are currently mounted.
4
+ *
5
+ * Redaction is implemented in the native capture path (it paints over the
6
+ * sensitive regions before the JPEG is encoded). The react-native-view-shot
7
+ * fallback has no such step, so a frame produced by it would contain the
8
+ * unredacted pixels.
9
+ *
10
+ * This registry lets the capture layer FAIL CLOSED: when the host app has
11
+ * asked for masking but only the unredacted path is available, the frame is
12
+ * skipped instead of uploaded. Losing a replay frame is always preferable to
13
+ * leaking the content the developer explicitly marked as sensitive.
14
+ *
15
+ * Lives in its own module so both the component and the capture function can
16
+ * use it without importing the SDK client (which would be circular).
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.registerMask = registerMask;
20
+ exports.unregisterMask = unregisterMask;
21
+ exports.hasActiveMasks = hasActiveMasks;
22
+ exports.__resetMaskRegistryForTests = __resetMaskRegistryForTests;
23
+ let activeMasks = 0;
24
+ /** Called by RepliqoMask on mount (when enabled). */
25
+ function registerMask() {
26
+ activeMasks++;
27
+ }
28
+ /** Called by RepliqoMask on unmount (or when disabled). */
29
+ function unregisterMask() {
30
+ activeMasks = Math.max(0, activeMasks - 1);
31
+ }
32
+ /** True when at least one mask region is currently mounted. */
33
+ function hasActiveMasks() {
34
+ return activeMasks > 0;
35
+ }
36
+ /** Test-only: reset the counter between cases. */
37
+ function __resetMaskRegistryForTests() {
38
+ activeMasks = 0;
39
+ }
@@ -12,9 +12,11 @@ export interface ScreenshotResult {
12
12
  *
13
13
  * 1. Native multi-window capture (Android) — uses View.draw() on all
14
14
  * app windows via WindowManagerGlobal reflection. Captures modals,
15
- * alerts, dialogs, overlays. No permissions needed.
15
+ * alerts, dialogs, overlays. No permissions needed. This is the ONLY
16
+ * path that applies <RepliqoMask> / password redaction.
16
17
  * 2. react-native-view-shot fallback — captures the main RN view only
17
- * (no modals or native dialogs)
18
+ * (no modals or native dialogs), WITHOUT redaction. Skipped entirely
19
+ * when masks are mounted (fail closed).
18
20
  * 3. null — if both are unavailable
19
21
  *
20
22
  * Never throws. Returns null on any failure.
@@ -4,6 +4,7 @@ exports.captureScreenshot = captureScreenshot;
4
4
  exports.__resetCaptureRefForTests = __resetCaptureRefForTests;
5
5
  const react_native_1 = require("react-native");
6
6
  const NativeScreenCapture_1 = require("./NativeScreenCapture");
7
+ const maskRegistry_1 = require("../privacy/maskRegistry");
7
8
  const TARGET_WIDTH = 390;
8
9
  const JPEG_QUALITY = 0.4;
9
10
  // --- view-shot fallback (lazy-loaded) ---
@@ -33,9 +34,11 @@ function resolveViewShot() {
33
34
  *
34
35
  * 1. Native multi-window capture (Android) — uses View.draw() on all
35
36
  * app windows via WindowManagerGlobal reflection. Captures modals,
36
- * alerts, dialogs, overlays. No permissions needed.
37
+ * alerts, dialogs, overlays. No permissions needed. This is the ONLY
38
+ * path that applies <RepliqoMask> / password redaction.
37
39
  * 2. react-native-view-shot fallback — captures the main RN view only
38
- * (no modals or native dialogs)
40
+ * (no modals or native dialogs), WITHOUT redaction. Skipped entirely
41
+ * when masks are mounted (fail closed).
39
42
  * 3. null — if both are unavailable
40
43
  *
41
44
  * Never throws. Returns null on any failure.
@@ -49,7 +52,12 @@ async function captureScreenshot() {
49
52
  // If native returns null (e.g. first frame not ready), fall through
50
53
  // to view-shot for this single frame rather than showing nothing.
51
54
  }
52
- // Strategy 2: react-native-view-shot fallback
55
+ // Strategy 2: react-native-view-shot fallback.
56
+ // FAIL CLOSED: this path cannot redact, so if the host app has mounted
57
+ // <RepliqoMask> regions we skip the frame rather than upload the
58
+ // sensitive pixels it explicitly asked to hide.
59
+ if ((0, maskRegistry_1.hasActiveMasks)())
60
+ return null;
53
61
  const captureScreen = resolveViewShot();
54
62
  if (!captureScreen)
55
63
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Session replay & analytics SDK for React Native. Captures screenshots (including modals/alerts), navigation, screen visits, and custom events.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,70 @@
1
+ import React, { useEffect } from 'react';
2
+ import { View, type ViewProps } from 'react-native';
3
+ import { registerMask, unregisterMask } from '../privacy/maskRegistry';
4
+
5
+ /**
6
+ * Marker value the native capture layer looks for. Must stay in sync with
7
+ * MaskCollector.MASK_MARKER on the Android side.
8
+ */
9
+ export const REPLIQO_MASK_MARKER = 'repliqo-mask';
10
+
11
+ export interface RepliqoMaskProps extends ViewProps {
12
+ /**
13
+ * Set to false to temporarily disable redaction for this subtree
14
+ * (e.g. behind a "show password" toggle you explicitly want captured).
15
+ * Defaults to true.
16
+ */
17
+ enabled?: boolean;
18
+ children?: React.ReactNode;
19
+ }
20
+
21
+ /**
22
+ * Redacts everything inside it from session-replay screenshots and heatmap
23
+ * tiles. The content still renders normally on the device — only the
24
+ * captured image is masked, and the masking happens ON-DEVICE, before the
25
+ * image is encoded, so sensitive pixels never reach the network.
26
+ *
27
+ * ```tsx
28
+ * <RepliqoMask>
29
+ * <Text>{user.email}</Text>
30
+ * <CreditCardForm />
31
+ * </RepliqoMask>
32
+ * ```
33
+ *
34
+ * Password inputs (`<TextInput secureTextEntry>`) are redacted
35
+ * AUTOMATICALLY and do not need to be wrapped.
36
+ *
37
+ * Implementation note: the marker travels through the `nativeID` prop,
38
+ * which React Native forwards to the underlying Android view. Any
39
+ * `nativeID` you pass yourself is preserved (appended after the marker) so
40
+ * existing view lookups keep working.
41
+ */
42
+ export function RepliqoMask({
43
+ enabled = true,
44
+ children,
45
+ nativeID,
46
+ ...props
47
+ }: RepliqoMaskProps) {
48
+ // Announce this region while it's mounted. If a capture can only be
49
+ // produced by the fallback path (which cannot redact), the SDK skips
50
+ // that frame instead of shipping the content this mask protects.
51
+ useEffect(() => {
52
+ if (!enabled) return;
53
+ registerMask();
54
+ return () => unregisterMask();
55
+ }, [enabled]);
56
+
57
+ const markerId = enabled
58
+ ? nativeID
59
+ ? `${REPLIQO_MASK_MARKER}:${nativeID}`
60
+ : REPLIQO_MASK_MARKER
61
+ : nativeID;
62
+
63
+ return (
64
+ <View {...props} nativeID={markerId} collapsable={false}>
65
+ {children}
66
+ </View>
67
+ );
68
+ }
69
+
70
+ RepliqoMask.displayName = 'RepliqoMask';
package/src/index.ts CHANGED
@@ -45,6 +45,8 @@ export type { ScreenshotResult } from './snapshot/captureScreenshot';
45
45
  // Components
46
46
  export { RepliqoScrollView } from './components/RepliqoScrollView';
47
47
  export { RepliqoFlatList } from './components/RepliqoFlatList';
48
+ export { RepliqoMask, REPLIQO_MASK_MARKER } from './components/RepliqoMask';
49
+ export type { RepliqoMaskProps } from './components/RepliqoMask';
48
50
 
49
51
  // Storage (offline persistence contract)
50
52
  export type { PersistentStorage } from './core/storage';
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Tracks how many <RepliqoMask> regions are currently mounted.
3
+ *
4
+ * Redaction is implemented in the native capture path (it paints over the
5
+ * sensitive regions before the JPEG is encoded). The react-native-view-shot
6
+ * fallback has no such step, so a frame produced by it would contain the
7
+ * unredacted pixels.
8
+ *
9
+ * This registry lets the capture layer FAIL CLOSED: when the host app has
10
+ * asked for masking but only the unredacted path is available, the frame is
11
+ * skipped instead of uploaded. Losing a replay frame is always preferable to
12
+ * leaking the content the developer explicitly marked as sensitive.
13
+ *
14
+ * Lives in its own module so both the component and the capture function can
15
+ * use it without importing the SDK client (which would be circular).
16
+ */
17
+
18
+ let activeMasks = 0;
19
+
20
+ /** Called by RepliqoMask on mount (when enabled). */
21
+ export function registerMask(): void {
22
+ activeMasks++;
23
+ }
24
+
25
+ /** Called by RepliqoMask on unmount (or when disabled). */
26
+ export function unregisterMask(): void {
27
+ activeMasks = Math.max(0, activeMasks - 1);
28
+ }
29
+
30
+ /** True when at least one mask region is currently mounted. */
31
+ export function hasActiveMasks(): boolean {
32
+ return activeMasks > 0;
33
+ }
34
+
35
+ /** Test-only: reset the counter between cases. */
36
+ export function __resetMaskRegistryForTests(): void {
37
+ activeMasks = 0;
38
+ }
@@ -3,6 +3,7 @@ import {
3
3
  isNativeScreenCaptureAvailable,
4
4
  captureNativeFrame,
5
5
  } from './NativeScreenCapture';
6
+ import { hasActiveMasks } from '../privacy/maskRegistry';
6
7
 
7
8
  export type CaptureSource = 'native' | 'viewshot';
8
9
 
@@ -48,9 +49,11 @@ function resolveViewShot(): ((opts: any) => Promise<string>) | null {
48
49
  *
49
50
  * 1. Native multi-window capture (Android) — uses View.draw() on all
50
51
  * app windows via WindowManagerGlobal reflection. Captures modals,
51
- * alerts, dialogs, overlays. No permissions needed.
52
+ * alerts, dialogs, overlays. No permissions needed. This is the ONLY
53
+ * path that applies <RepliqoMask> / password redaction.
52
54
  * 2. react-native-view-shot fallback — captures the main RN view only
53
- * (no modals or native dialogs)
55
+ * (no modals or native dialogs), WITHOUT redaction. Skipped entirely
56
+ * when masks are mounted (fail closed).
54
57
  * 3. null — if both are unavailable
55
58
  *
56
59
  * Never throws. Returns null on any failure.
@@ -64,7 +67,12 @@ export async function captureScreenshot(): Promise<ScreenshotResult | null> {
64
67
  // to view-shot for this single frame rather than showing nothing.
65
68
  }
66
69
 
67
- // Strategy 2: react-native-view-shot fallback
70
+ // Strategy 2: react-native-view-shot fallback.
71
+ // FAIL CLOSED: this path cannot redact, so if the host app has mounted
72
+ // <RepliqoMask> regions we skip the frame rather than upload the
73
+ // sensitive pixels it explicitly asked to hide.
74
+ if (hasActiveMasks()) return null;
75
+
68
76
  const captureScreen = resolveViewShot();
69
77
  if (!captureScreen) return null;
70
78