clintplay-react-native 0.2.0 → 0.2.1
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/README.md +3 -3
- package/android/src/main/java/digital/clint/clintplay/ClintplayRNModule.kt +49 -1
- package/dist/index.d.mts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +64 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +64 -35
- package/dist/index.mjs.map +1 -1
- package/ios/ClintplayRN.mm +107 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Session capture SDK for React Native / Expo apps — companion to [`clintplay-sdk`](../sdk) (web). Captures lifecycle, screen dimensions, navigation, JS errors, network requests (Fetch & XHR/Axios), console output, touches (screen-relative & target-relative), and native visual wireframe replay on iOS and Android.
|
|
4
4
|
|
|
5
|
-
> **Status:** `0.2.
|
|
5
|
+
> **Status:** `0.2.1` — Production-ready JS runtime and Native TurboModule. Supports native view hierarchy traversal on iOS (`ios/ClintplayRN.mm`) and Android (`android/.../ClintplayRNModule.kt`), visual wireframe session replay, network interception for `fetch` and `XMLHttpRequest`/Axios, automatic navigation tracking, and intelligent auto-masking for sensitive fields.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -81,7 +81,7 @@ export default function RootLayout() {
|
|
|
81
81
|
| `enabled` | `boolean` | `true` | Master killswitch. Set to `false` to disable all telemetry. |
|
|
82
82
|
| `sampleRate` | `number` | `1.0` | Probabilistic sampling rate between `0.0` and `1.0`. |
|
|
83
83
|
| `captureWireframe` | `boolean` | `false` | Enable native view-hierarchy visual wireframe capture for session replay. |
|
|
84
|
-
| `wireframeIntervalMs` | `number` | `1000` | Wireframe snapshot interval in milliseconds. |
|
|
84
|
+
| `wireframeIntervalMs` | `number` | `1000` | Wireframe snapshot interval in milliseconds. A snapshot identical to the previous one is not re-sent (an unchanged screen is re-sent every 30 s), and a snapshot is also taken ~400 ms after every touch end and navigation, so screens visited for less than the interval still reach the replay. |
|
|
85
85
|
| `captureNetwork` | `boolean` | `true` | Intercept `globalThis.fetch` and `XMLHttpRequest` (including Axios) requests. |
|
|
86
86
|
| `captureConsole` | `boolean` | `false` | Intercept `console.warn` and `console.error` logs. |
|
|
87
87
|
| `captureConsoleLog` | `boolean` | `false` | Intercept `console.log` logs when `captureConsole: true`. |
|
|
@@ -104,7 +104,7 @@ export default function RootLayout() {
|
|
|
104
104
|
| `network` | `fetch` & `XMLHttpRequest` / Axios | **On** | HTTP method, URL (sanitized), status code, duration, and errors. Protected against double-counting. |
|
|
105
105
|
| `navigation` | Navigation Trackers & `trackScreen` | **On (Tracker)** | Screen path changes (`to`, `from`). Automatic via `<ClintplayNavigationTracker />` or React Navigation listener. |
|
|
106
106
|
| `touch` | `<ClintplayCaptureProvider>` | **Provider** | Tap / touch gestures with both screen-relative coordinates (`screenX`, `screenY`) and target-relative coordinates (`x`, `y`). |
|
|
107
|
-
| `wireframe` | Native TurboModule | **Opt-in** | Visual UI hierarchy snapshots (view rects,
|
|
107
|
+
| `wireframe` | Native TurboModule | **Opt-in** | Visual UI hierarchy snapshots (view rects, background colors, text with font size, color and alignment — plus weight on iOS, and bold on Android —, masking) across iOS and Android. Sent on change, after interactions, and every 30 s while unchanged. |
|
|
108
108
|
| `log` | Console Interceptor | **Opt-in** | Console output (`warn`, `error`, and optional `log`). |
|
|
109
109
|
| `identify` | `Clintplay.identify()` | **Manual** | Associating sessions with authenticated users and custom traits. |
|
|
110
110
|
|
|
@@ -2,13 +2,19 @@ package digital.clint.clintplay
|
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
4
|
import android.content.Context
|
|
5
|
+
import android.graphics.Typeface
|
|
5
6
|
import android.graphics.drawable.ColorDrawable
|
|
6
7
|
import android.graphics.drawable.Drawable
|
|
7
8
|
import android.os.Build
|
|
8
9
|
import android.os.Handler
|
|
9
10
|
import android.os.Looper
|
|
10
11
|
import android.text.InputType
|
|
12
|
+
import android.text.Spanned
|
|
11
13
|
import android.text.method.PasswordTransformationMethod
|
|
14
|
+
import android.text.style.AbsoluteSizeSpan
|
|
15
|
+
import android.text.style.ForegroundColorSpan
|
|
16
|
+
import android.text.style.StyleSpan
|
|
17
|
+
import android.view.Gravity
|
|
12
18
|
import android.view.View
|
|
13
19
|
import android.view.ViewGroup
|
|
14
20
|
import android.view.WindowManager
|
|
@@ -112,6 +118,45 @@ class ClintplayRNModule(reactContext: ReactApplicationContext) :
|
|
|
112
118
|
return false
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
// React Native sets size and colour as spans on the text, so the spans at the first
|
|
122
|
+
// character win over the TextView's own defaults. Weight is only sent when bold is
|
|
123
|
+
// actually detected: RN carries it in its own CustomStyleSpan, which this does not read,
|
|
124
|
+
// and a guessed 400 would look measured. Best effort: the text alone already makes the
|
|
125
|
+
// replay readable.
|
|
126
|
+
private fun putTextStyle(node: JSONObject, view: TextView, density: Float) {
|
|
127
|
+
try {
|
|
128
|
+
val text = view.text
|
|
129
|
+
val safeDensity = if (density > 0f) density else 1f
|
|
130
|
+
var sizePx = view.textSize
|
|
131
|
+
var color = view.currentTextColor
|
|
132
|
+
var bold = view.typeface?.isBold == true
|
|
133
|
+
if (text is Spanned && text.isNotEmpty()) {
|
|
134
|
+
text.getSpans(0, 1, AbsoluteSizeSpan::class.java).lastOrNull()?.let { span ->
|
|
135
|
+
sizePx = if (span.dip) span.size * safeDensity else span.size.toFloat()
|
|
136
|
+
}
|
|
137
|
+
text.getSpans(0, 1, ForegroundColorSpan::class.java).lastOrNull()?.let { span ->
|
|
138
|
+
color = span.foregroundColor
|
|
139
|
+
}
|
|
140
|
+
if (text.getSpans(0, 1, StyleSpan::class.java).any { (it.style and Typeface.BOLD) != 0 }) {
|
|
141
|
+
bold = true
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
node.put("fontSize", round(sizePx / safeDensity * 10.0) / 10.0)
|
|
145
|
+
if (bold) node.put("fontWeight", 700)
|
|
146
|
+
node.put("color", formatColor(color))
|
|
147
|
+
val horizontal = Gravity.getAbsoluteGravity(view.gravity, view.layoutDirection) and
|
|
148
|
+
Gravity.HORIZONTAL_GRAVITY_MASK
|
|
149
|
+
node.put(
|
|
150
|
+
"textAlign",
|
|
151
|
+
when (horizontal) {
|
|
152
|
+
Gravity.CENTER_HORIZONTAL -> "center"
|
|
153
|
+
Gravity.RIGHT -> "right"
|
|
154
|
+
else -> "left"
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
} catch (_: Throwable) {}
|
|
158
|
+
}
|
|
159
|
+
|
|
115
160
|
private fun serializeView(
|
|
116
161
|
view: View?,
|
|
117
162
|
nodeCounter: AtomicInteger,
|
|
@@ -206,6 +251,9 @@ class ClintplayRNModule(reactContext: ReactApplicationContext) :
|
|
|
206
251
|
var extractedText: String? = null
|
|
207
252
|
if (view is TextView) {
|
|
208
253
|
extractedText = view.text?.toString()
|
|
254
|
+
if (!isMasked && !extractedText.isNullOrEmpty()) {
|
|
255
|
+
putTextStyle(nodeObj, view, density)
|
|
256
|
+
}
|
|
209
257
|
}
|
|
210
258
|
|
|
211
259
|
if (extractedText != null) {
|
|
@@ -328,7 +376,7 @@ class ClintplayRNModule(reactContext: ReactApplicationContext) :
|
|
|
328
376
|
|
|
329
377
|
companion object {
|
|
330
378
|
const val NAME = "ClintplayRN"
|
|
331
|
-
private const val VERSION = "0.2.
|
|
379
|
+
private const val VERSION = "0.2.1"
|
|
332
380
|
private const val FALLBACK_JSON = "{\"version\":1,\"nodes\":[]}"
|
|
333
381
|
|
|
334
382
|
private val NO_METHOD_SENTINEL = Any()
|
package/dist/index.d.mts
CHANGED
|
@@ -186,6 +186,10 @@ interface Recorder {
|
|
|
186
186
|
start(): void;
|
|
187
187
|
stop(): void;
|
|
188
188
|
takeWireframeSnapshot(): void;
|
|
189
|
+
/** Captura depois que a interação assenta (toque, navegação). Chamadas próximas viram uma. */
|
|
190
|
+
scheduleWireframeSnapshot(): void;
|
|
191
|
+
/** A próxima captura é enviada mesmo igual à anterior (sessão nova precisa da tela). */
|
|
192
|
+
resetWireframeBaseline(): void;
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
interface NetworkInterceptorDeps {
|
package/dist/index.d.ts
CHANGED
|
@@ -186,6 +186,10 @@ interface Recorder {
|
|
|
186
186
|
start(): void;
|
|
187
187
|
stop(): void;
|
|
188
188
|
takeWireframeSnapshot(): void;
|
|
189
|
+
/** Captura depois que a interação assenta (toque, navegação). Chamadas próximas viram uma. */
|
|
190
|
+
scheduleWireframeSnapshot(): void;
|
|
191
|
+
/** A próxima captura é enviada mesmo igual à anterior (sessão nova precisa da tela). */
|
|
192
|
+
resetWireframeBaseline(): void;
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
interface NetworkInterceptorDeps {
|
package/dist/index.js
CHANGED
|
@@ -153,6 +153,8 @@ var Transport = class {
|
|
|
153
153
|
return h;
|
|
154
154
|
}
|
|
155
155
|
};
|
|
156
|
+
var SNAPSHOT_AFTER_INTERACTION_MS = 400;
|
|
157
|
+
var UNCHANGED_RESEND_MS = 3e4;
|
|
156
158
|
function mapAppState(state2) {
|
|
157
159
|
if (state2 === "active") return "active";
|
|
158
160
|
if (state2 === "background") return "background";
|
|
@@ -165,16 +167,25 @@ function createRecorder(deps) {
|
|
|
165
167
|
const cleanups = [];
|
|
166
168
|
let started = false;
|
|
167
169
|
let wireframeTimer = null;
|
|
168
|
-
|
|
170
|
+
let interactionTimer = null;
|
|
171
|
+
let lastNodesKey = null;
|
|
172
|
+
let lastSentAt = 0;
|
|
173
|
+
function takeWireframeSnapshot(force = false) {
|
|
169
174
|
if (!deps.captureViewHierarchy) return;
|
|
170
175
|
try {
|
|
171
176
|
const raw = deps.captureViewHierarchy();
|
|
172
177
|
if (!raw || typeof raw !== "string") return;
|
|
173
178
|
const payload = JSON.parse(raw);
|
|
174
179
|
if (payload && Array.isArray(payload.nodes)) {
|
|
180
|
+
if (force && payload.nodes.length === 0) return;
|
|
181
|
+
const key = JSON.stringify(payload.nodes);
|
|
182
|
+
const now = deps.now();
|
|
183
|
+
if (!force && key === lastNodesKey && now - lastSentAt < UNCHANGED_RESEND_MS) return;
|
|
184
|
+
lastNodesKey = key;
|
|
185
|
+
lastSentAt = now;
|
|
175
186
|
deps.onEvent({
|
|
176
187
|
type: "wireframe",
|
|
177
|
-
ts:
|
|
188
|
+
ts: now,
|
|
178
189
|
version: typeof payload.version === "number" ? payload.version : 1,
|
|
179
190
|
nodes: payload.nodes
|
|
180
191
|
});
|
|
@@ -182,6 +193,20 @@ function createRecorder(deps) {
|
|
|
182
193
|
} catch {
|
|
183
194
|
}
|
|
184
195
|
}
|
|
196
|
+
function scheduleWireframeSnapshot() {
|
|
197
|
+
if (!deps.captureWireframe || !deps.captureViewHierarchy) return;
|
|
198
|
+
if (interactionTimer) clearTimeout(interactionTimer);
|
|
199
|
+
interactionTimer = setTimeout(() => {
|
|
200
|
+
interactionTimer = null;
|
|
201
|
+
takeWireframeSnapshot();
|
|
202
|
+
}, SNAPSHOT_AFTER_INTERACTION_MS);
|
|
203
|
+
}
|
|
204
|
+
function cancelScheduledSnapshot() {
|
|
205
|
+
if (interactionTimer) {
|
|
206
|
+
clearTimeout(interactionTimer);
|
|
207
|
+
interactionTimer = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
185
210
|
function startWireframeTimer() {
|
|
186
211
|
if (!deps.captureWireframe || !deps.captureViewHierarchy) return;
|
|
187
212
|
if (wireframeTimer) return;
|
|
@@ -199,7 +224,11 @@ function createRecorder(deps) {
|
|
|
199
224
|
}
|
|
200
225
|
}
|
|
201
226
|
return {
|
|
202
|
-
takeWireframeSnapshot,
|
|
227
|
+
takeWireframeSnapshot: () => takeWireframeSnapshot(),
|
|
228
|
+
scheduleWireframeSnapshot,
|
|
229
|
+
resetWireframeBaseline() {
|
|
230
|
+
lastNodesKey = null;
|
|
231
|
+
},
|
|
203
232
|
start() {
|
|
204
233
|
if (started) return;
|
|
205
234
|
started = true;
|
|
@@ -223,7 +252,9 @@ function createRecorder(deps) {
|
|
|
223
252
|
if (mapped === "active") {
|
|
224
253
|
startWireframeTimer();
|
|
225
254
|
} else {
|
|
255
|
+
if (wireframeTimer) takeWireframeSnapshot(true);
|
|
226
256
|
stopWireframeTimer();
|
|
257
|
+
cancelScheduledSnapshot();
|
|
227
258
|
}
|
|
228
259
|
}
|
|
229
260
|
});
|
|
@@ -281,6 +312,7 @@ function createRecorder(deps) {
|
|
|
281
312
|
if (!started) return;
|
|
282
313
|
started = false;
|
|
283
314
|
stopWireframeTimer();
|
|
315
|
+
cancelScheduledSnapshot();
|
|
284
316
|
for (const fn of cleanups.splice(0)) {
|
|
285
317
|
try {
|
|
286
318
|
fn();
|
|
@@ -885,7 +917,7 @@ function captureViewHierarchy() {
|
|
|
885
917
|
}
|
|
886
918
|
|
|
887
919
|
// package.json
|
|
888
|
-
var version = "0.2.
|
|
920
|
+
var version = "0.2.1";
|
|
889
921
|
var CLINTPLAY_MASK_ID = "clintplay-mask";
|
|
890
922
|
var ClintplayMaskView = React5__default.default.forwardRef(function ClintplayMaskView2({ children, testID, ...rest }, ref) {
|
|
891
923
|
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
@@ -1285,10 +1317,18 @@ function init(rawConfig) {
|
|
|
1285
1317
|
buffer,
|
|
1286
1318
|
session,
|
|
1287
1319
|
transport,
|
|
1288
|
-
recorder: {
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1320
|
+
recorder: {
|
|
1321
|
+
start: () => {
|
|
1322
|
+
},
|
|
1323
|
+
stop: () => {
|
|
1324
|
+
},
|
|
1325
|
+
takeWireframeSnapshot: () => {
|
|
1326
|
+
},
|
|
1327
|
+
scheduleWireframeSnapshot: () => {
|
|
1328
|
+
},
|
|
1329
|
+
resetWireframeBaseline: () => {
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1292
1332
|
networkInterceptor: null,
|
|
1293
1333
|
consoleInterceptor: null,
|
|
1294
1334
|
offlineQueue: rawConfig.asyncStorage ? new OfflineQueue({
|
|
@@ -1333,17 +1373,16 @@ function init(rawConfig) {
|
|
|
1333
1373
|
captureConsoleLog: rawConfig.captureConsoleLog
|
|
1334
1374
|
});
|
|
1335
1375
|
}
|
|
1336
|
-
|
|
1337
|
-
type: "meta",
|
|
1338
|
-
ts: Date.now(),
|
|
1339
|
-
sessionId: session.current().sessionId,
|
|
1340
|
-
sdkVersion: SDK_VERSION,
|
|
1341
|
-
device: deviceInfo()
|
|
1342
|
-
});
|
|
1376
|
+
pushSessionMeta(local);
|
|
1343
1377
|
recorder.start();
|
|
1344
1378
|
local.networkInterceptor?.start();
|
|
1345
1379
|
local.consoleInterceptor?.start();
|
|
1346
|
-
_setSink(
|
|
1380
|
+
_setSink((event) => {
|
|
1381
|
+
onEvent(event);
|
|
1382
|
+
if (event.type === "navigation" || event.type === "touch" && event.kind === "end") {
|
|
1383
|
+
recorder.scheduleWireframeSnapshot();
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1347
1386
|
if (local.offlineQueue) {
|
|
1348
1387
|
const q = local.offlineQueue;
|
|
1349
1388
|
void q.hydrate().then(() => q.drain()).catch(
|
|
@@ -1354,13 +1393,7 @@ function init(rawConfig) {
|
|
|
1354
1393
|
if (local.session.isStale()) {
|
|
1355
1394
|
await flushOnce(local);
|
|
1356
1395
|
if (local.session.rotateIfStale()) {
|
|
1357
|
-
local
|
|
1358
|
-
type: "meta",
|
|
1359
|
-
ts: Date.now(),
|
|
1360
|
-
sessionId: local.session.current().sessionId,
|
|
1361
|
-
sdkVersion: SDK_VERSION,
|
|
1362
|
-
device: deviceInfo()
|
|
1363
|
-
});
|
|
1396
|
+
pushSessionMeta(local);
|
|
1364
1397
|
}
|
|
1365
1398
|
}
|
|
1366
1399
|
void flushOnce(local);
|
|
@@ -1379,13 +1412,7 @@ function identify(userId, traits) {
|
|
|
1379
1412
|
}
|
|
1380
1413
|
const rotated = state.session.setUser(normalizedUserId);
|
|
1381
1414
|
if (rotated) {
|
|
1382
|
-
state
|
|
1383
|
-
type: "meta",
|
|
1384
|
-
ts: Date.now(),
|
|
1385
|
-
sessionId: state.session.current().sessionId,
|
|
1386
|
-
sdkVersion: SDK_VERSION,
|
|
1387
|
-
device: deviceInfo()
|
|
1388
|
-
});
|
|
1415
|
+
pushSessionMeta(state);
|
|
1389
1416
|
}
|
|
1390
1417
|
state.buffer.push({
|
|
1391
1418
|
type: "identify",
|
|
@@ -1403,13 +1430,17 @@ function reset(options) {
|
|
|
1403
1430
|
if (options?.clearOfflineQueue && state.offlineQueue) {
|
|
1404
1431
|
void state.offlineQueue.clear();
|
|
1405
1432
|
}
|
|
1406
|
-
state
|
|
1433
|
+
pushSessionMeta(state);
|
|
1434
|
+
}
|
|
1435
|
+
function pushSessionMeta(s) {
|
|
1436
|
+
s.buffer.push({
|
|
1407
1437
|
type: "meta",
|
|
1408
1438
|
ts: Date.now(),
|
|
1409
|
-
sessionId:
|
|
1439
|
+
sessionId: s.session.current().sessionId,
|
|
1410
1440
|
sdkVersion: SDK_VERSION,
|
|
1411
1441
|
device: deviceInfo()
|
|
1412
1442
|
});
|
|
1443
|
+
s.recorder.resetWireframeBaseline();
|
|
1413
1444
|
}
|
|
1414
1445
|
function trackScreen(path, from) {
|
|
1415
1446
|
if (!state?.active) return;
|
|
@@ -1420,9 +1451,7 @@ function trackScreen(path, from) {
|
|
|
1420
1451
|
...from ? { from } : {}
|
|
1421
1452
|
});
|
|
1422
1453
|
state.session.touch();
|
|
1423
|
-
|
|
1424
|
-
state.recorder.takeWireframeSnapshot();
|
|
1425
|
-
}
|
|
1454
|
+
state.recorder.scheduleWireframeSnapshot();
|
|
1426
1455
|
}
|
|
1427
1456
|
function reportError(err) {
|
|
1428
1457
|
if (!state?.active) return;
|