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 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.0` — 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.
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, styles, text, masking) across iOS and Android. |
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.0"
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
- function takeWireframeSnapshot() {
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: deps.now(),
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.0";
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: { start: () => {
1289
- }, stop: () => {
1290
- }, takeWireframeSnapshot: () => {
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
- buffer.push({
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(onEvent);
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.buffer.push({
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.buffer.push({
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.buffer.push({
1433
+ pushSessionMeta(state);
1434
+ }
1435
+ function pushSessionMeta(s) {
1436
+ s.buffer.push({
1407
1437
  type: "meta",
1408
1438
  ts: Date.now(),
1409
- sessionId: state.session.current().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
- if (state.config.captureWireframe) {
1424
- state.recorder.takeWireframeSnapshot();
1425
- }
1454
+ state.recorder.scheduleWireframeSnapshot();
1426
1455
  }
1427
1456
  function reportError(err) {
1428
1457
  if (!state?.active) return;