@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.
- package/INTEGRATION_GUIDE.md +73 -0
- package/android/src/main/java/com/repliqo/screencapture/MaskCollector.java +159 -0
- package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +46 -0
- package/dist/components/RepliqoMask.d.ts +41 -0
- package/dist/components/RepliqoMask.js +86 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -1
- package/dist/privacy/maskRegistry.d.ts +24 -0
- package/dist/privacy/maskRegistry.js +39 -0
- package/dist/snapshot/captureScreenshot.d.ts +4 -2
- package/dist/snapshot/captureScreenshot.js +11 -3
- package/package.json +1 -1
- package/src/components/RepliqoMask.tsx +70 -0
- package/src/index.ts +2 -0
- package/src/privacy/maskRegistry.ts +38 -0
- package/src/snapshot/captureScreenshot.ts +11 -3
package/INTEGRATION_GUIDE.md
CHANGED
|
@@ -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.
|
|
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
|
|