@rejourneyco/react-native 1.0.7 → 1.0.9

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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
  3. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  4. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  5. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  6. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
  7. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  8. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
  9. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
  10. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  11. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  12. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
  13. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  14. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
  15. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  16. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  17. package/ios/Engine/DeviceRegistrar.swift +13 -3
  18. package/ios/Engine/RejourneyImpl.swift +202 -133
  19. package/ios/Recording/AnrSentinel.swift +58 -25
  20. package/ios/Recording/InteractionRecorder.swift +29 -0
  21. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  22. package/ios/Recording/ReplayOrchestrator.swift +241 -147
  23. package/ios/Recording/SegmentDispatcher.swift +155 -13
  24. package/ios/Recording/SpecialCases.swift +614 -0
  25. package/ios/Recording/StabilityMonitor.swift +42 -34
  26. package/ios/Recording/TelemetryPipeline.swift +38 -3
  27. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  28. package/ios/Recording/VisualCapture.swift +104 -28
  29. package/ios/Rejourney.mm +27 -8
  30. package/ios/Utility/ImageBlur.swift +0 -1
  31. package/lib/commonjs/index.js +32 -20
  32. package/lib/commonjs/sdk/autoTracking.js +162 -11
  33. package/lib/commonjs/sdk/constants.js +2 -2
  34. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  35. package/lib/commonjs/sdk/utils.js +1 -1
  36. package/lib/module/index.js +32 -20
  37. package/lib/module/sdk/autoTracking.js +162 -11
  38. package/lib/module/sdk/constants.js +2 -2
  39. package/lib/module/sdk/networkInterceptor.js +84 -4
  40. package/lib/module/sdk/utils.js +1 -1
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  43. package/lib/typescript/sdk/constants.d.ts +2 -2
  44. package/lib/typescript/types/index.d.ts +15 -8
  45. package/package.json +4 -4
  46. package/src/NativeRejourney.ts +8 -5
  47. package/src/index.ts +46 -29
  48. package/src/sdk/autoTracking.ts +176 -11
  49. package/src/sdk/constants.ts +2 -2
  50. package/src/sdk/networkInterceptor.ts +110 -1
  51. package/src/sdk/utils.ts +1 -1
  52. package/src/types/index.ts +16 -9
@@ -26,8 +26,11 @@ import android.graphics.Rect
26
26
  import android.os.Handler
27
27
  import android.os.Looper
28
28
  import android.os.SystemClock
29
+ import android.view.TextureView
29
30
  import android.view.View
31
+ import android.view.ViewGroup
30
32
  import android.view.WindowManager
33
+ import android.widget.EditText
31
34
  import com.rejourney.engine.DiagnosticLog
32
35
  import com.rejourney.utility.gzipCompress
33
36
  import java.io.ByteArrayOutputStream
@@ -60,7 +63,7 @@ class VisualCapture private constructor(private val context: Context) {
60
63
  get() = instance
61
64
  }
62
65
 
63
- var snapshotInterval: Double = 0.5
66
+ var snapshotInterval: Double = 1.0
64
67
  var quality: Float = 0.5f
65
68
 
66
69
  val isCapturing: Boolean
@@ -91,18 +94,17 @@ class VisualCapture private constructor(private val context: Context) {
91
94
 
92
95
  // Current activity reference
93
96
  private var currentActivity: WeakReference<Activity>? = null
97
+
94
98
 
95
99
  fun setCurrentActivity(activity: Activity?) {
96
100
  currentActivity = if (activity != null) WeakReference(activity) else null
97
- DiagnosticLog.notice("[VisualCapture] setCurrentActivity: ${activity?.javaClass?.simpleName ?: "null"}")
101
+ DiagnosticLog.trace("[VisualCapture] setCurrentActivity: ${activity?.javaClass?.simpleName ?: "null"}")
98
102
  }
99
103
 
100
104
  fun beginCapture(sessionOrigin: Long) {
101
- DiagnosticLog.notice("[VisualCapture] beginCapture called, currentActivity=${currentActivity?.get()?.javaClass?.simpleName ?: "null"}, state=${stateMachine.currentState}")
102
- DiagnosticLog.trace("[VisualCapture] beginCapture called, currentActivity=${currentActivity?.get()?.javaClass?.simpleName ?: "null"}")
105
+ DiagnosticLog.trace("[VisualCapture] beginCapture called, currentActivity=${currentActivity?.get()?.javaClass?.simpleName ?: "null"}, state=${stateMachine.currentState}")
103
106
  if (!stateMachine.transition(CaptureState.CAPTURING)) {
104
- DiagnosticLog.notice("[VisualCapture] beginCapture REJECTED - state transition failed from ${stateMachine.currentState}")
105
- DiagnosticLog.trace("[VisualCapture] beginCapture failed - state transition rejected")
107
+ DiagnosticLog.trace("[VisualCapture] beginCapture REJECTED - state transition failed from ${stateMachine.currentState}")
106
108
  return
107
109
  }
108
110
  sessionEpoch = sessionOrigin
@@ -116,7 +118,6 @@ class VisualCapture private constructor(private val context: Context) {
116
118
  }
117
119
  }
118
120
 
119
- DiagnosticLog.notice("[VisualCapture] Starting capture timer with interval=${snapshotInterval}s")
120
121
  DiagnosticLog.trace("[VisualCapture] Starting capture timer with interval=${snapshotInterval}s")
121
122
  startCaptureTimer()
122
123
  }
@@ -138,6 +139,21 @@ class VisualCapture private constructor(private val context: Context) {
138
139
  flushBufferToDisk()
139
140
  }
140
141
 
142
+ /** Submit any buffered frames to the upload pipeline immediately
143
+ * (regardless of batch size threshold). Packages synchronously to
144
+ * avoid race conditions during backgrounding. */
145
+ fun flushBufferToNetwork() {
146
+ // Take frames from buffer synchronously (not via async sendScreenshots)
147
+ val images = stateLock.withLock {
148
+ val copy = screenshots.toList()
149
+ screenshots.clear()
150
+ copy
151
+ }
152
+ if (images.isEmpty()) return
153
+ // Package and submit synchronously on this thread
154
+ packageAndShip(images, sessionEpoch)
155
+ }
156
+
141
157
  fun activateDeferredMode() {
142
158
  deferredUntilCommit = true
143
159
  }
@@ -155,6 +171,10 @@ class VisualCapture private constructor(private val context: Context) {
155
171
  redactionMask.remove(view)
156
172
  }
157
173
 
174
+ fun invalidateMaskCache() {
175
+ redactionMask.invalidateCache()
176
+ }
177
+
158
178
  fun configure(snapshotInterval: Double, jpegQuality: Double) {
159
179
  this.snapshotInterval = snapshotInterval
160
180
  this.quality = jpegQuality.toFloat()
@@ -165,14 +185,14 @@ class VisualCapture private constructor(private val context: Context) {
165
185
  }
166
186
 
167
187
  fun snapshotNow() {
168
- mainHandler.post { captureFrame() }
188
+ mainHandler.post { captureFrame(force = true) }
169
189
  }
170
190
 
171
191
  private fun startCaptureTimer() {
172
192
  stopCaptureTimer()
173
193
  captureRunnable = object : Runnable {
174
194
  override fun run() {
175
- captureFrame()
195
+ captureFrame(force = false)
176
196
  mainHandler.postDelayed(this, (snapshotInterval * 1000).toLong())
177
197
  }
178
198
  }
@@ -184,101 +204,178 @@ class VisualCapture private constructor(private val context: Context) {
184
204
  captureRunnable = null
185
205
  }
186
206
 
187
- private fun captureFrame() {
207
+ private fun captureFrame(force: Boolean = false) {
188
208
  val currentFrameNum = frameCounter.get()
189
- // Log first 3 frames at notice level
190
209
  if (currentFrameNum < 3) {
191
- DiagnosticLog.notice("[VisualCapture] captureFrame #$currentFrameNum, state=${stateMachine.currentState}, activity=${currentActivity?.get()?.javaClass?.simpleName ?: "null"}")
210
+ DiagnosticLog.trace("[VisualCapture] captureFrame #$currentFrameNum, state=${stateMachine.currentState}, activity=${currentActivity?.get()?.javaClass?.simpleName ?: "null"}")
192
211
  }
193
212
 
194
213
  if (stateMachine.currentState != CaptureState.CAPTURING) {
195
- DiagnosticLog.notice("[VisualCapture] captureFrame skipped - state=${stateMachine.currentState}")
196
214
  DiagnosticLog.trace("[VisualCapture] captureFrame skipped - state=${stateMachine.currentState}")
197
215
  return
198
216
  }
199
217
 
200
218
  val activity = currentActivity?.get()
201
219
  if (activity == null) {
202
- if (currentFrameNum < 3) {
203
- DiagnosticLog.notice("[VisualCapture] captureFrame skipped - NO ACTIVITY")
204
- }
205
220
  DiagnosticLog.trace("[VisualCapture] captureFrame skipped - no activity")
206
221
  return
207
222
  }
208
223
 
224
+ // Refresh map detection state (very cheap shallow walk)
225
+ SpecialCases.shared.refreshMapState(activity)
226
+
227
+ // Map stutter prevention: when a map view is visible and its camera
228
+ // is still moving (user gesture or animation), skip decorView.draw()
229
+ // entirely — this call triggers GPU readback on SurfaceView/TextureView
230
+ // map tiles which causes visible stutter. We resume capture at 1 FPS
231
+ // once the map SDK reports idle.
232
+ if (!force && SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle) {
233
+ if (currentFrameNum < 3 || currentFrameNum % 30 == 0L) {
234
+ DiagnosticLog.trace("[VisualCapture] SKIPPING capture - map moving (mapIdle=false)")
235
+ }
236
+ return
237
+ }
238
+
209
239
  val frameStart = SystemClock.elapsedRealtime()
210
240
 
211
241
  try {
212
- val decorView = activity.window?.decorView ?: return
242
+ val window = activity.window ?: return
243
+ val decorView = window.decorView
213
244
  val bounds = Rect()
214
245
  decorView.getWindowVisibleDisplayFrame(bounds)
215
246
 
216
247
  if (bounds.width() <= 0 || bounds.height() <= 0) return
217
248
 
218
- val redactRects = redactionMask.computeRects()
249
+ val redactRects = redactionMask.computeRects(decorView)
219
250
 
220
- // Use lower scale to reduce encoding time significantly
221
251
  val screenScale = 1.25f
222
252
  val scaledWidth = (bounds.width() / screenScale).toInt()
223
253
  val scaledHeight = (bounds.height() / screenScale).toInt()
224
254
 
255
+ // 1. Draw the View tree (captures everything except GPU surfaces)
225
256
  val bitmap = Bitmap.createBitmap(scaledWidth, scaledHeight, Bitmap.Config.ARGB_8888)
226
257
  val canvas = Canvas(bitmap)
227
258
  canvas.scale(1f / screenScale, 1f / screenScale)
228
-
229
259
  decorView.draw(canvas)
230
260
 
231
- // Apply redactions
232
- if (redactRects.isNotEmpty()) {
233
- val paint = Paint().apply {
234
- color = Color.BLACK
235
- style = Paint.Style.FILL
236
- }
237
- for (rect in redactRects) {
238
- if (rect.width() > 0 && rect.height() > 0) {
239
- canvas.drawRect(
240
- rect.left / screenScale,
241
- rect.top / screenScale,
242
- rect.right / screenScale,
243
- rect.bottom / screenScale,
244
- paint
245
- )
246
- }
247
- }
248
- }
261
+ // 2. Composite GPU surfaces (TextureView/SurfaceView) on top.
262
+ // decorView.draw() renders these as black; we grab their pixels
263
+ // directly and paint them at the correct position.
264
+ compositeGpuSurfaces(decorView, canvas, screenScale)
249
265
 
250
- // Compress to JPEG
251
- val stream = ByteArrayOutputStream()
252
- bitmap.compress(Bitmap.CompressFormat.JPEG, (quality * 100).toInt(), stream)
253
- bitmap.recycle()
266
+ processCapture(bitmap, redactRects, screenScale, frameStart, force)
254
267
 
255
- val data = stream.toByteArray()
256
- val captureTs = System.currentTimeMillis()
257
- val frameNum = frameCounter.incrementAndGet()
258
-
259
- // Log first frame and every 30 frames
260
- if (frameNum == 1L) {
261
- DiagnosticLog.notice("[VisualCapture] First frame captured! size=${data.size} bytes")
268
+ } catch (e: Exception) {
269
+ DiagnosticLog.fault("Frame capture failed: ${e.message}")
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Find all TextureView instances in the hierarchy and draw their GPU-rendered
275
+ * content onto the capture canvas at the correct position. decorView.draw()
276
+ * renders TextureView/SurfaceView as black; this fills in the actual pixels.
277
+ *
278
+ * Mapbox uses SurfaceView by default, so we use MapView.snapshot() to capture
279
+ * the map and composite it at the correct position.
280
+ */
281
+ private fun compositeGpuSurfaces(root: View, canvas: Canvas, screenScale: Float) {
282
+ findTextureViews(root) { tv ->
283
+ try {
284
+ val tvBitmap = tv.bitmap ?: return@findTextureViews
285
+ val loc = IntArray(2)
286
+ tv.getLocationInWindow(loc)
287
+ canvas.drawBitmap(tvBitmap, loc[0].toFloat(), loc[1].toFloat(), null)
288
+ tvBitmap.recycle()
289
+ } catch (_: Exception) {
290
+ // Safety: never crash if TextureView.getBitmap() fails
262
291
  }
263
- if (frameNum % 30 == 0L) {
264
- val frameDurationMs = (SystemClock.elapsedRealtime() - frameStart).toDouble()
265
- val isMainThread = Looper.myLooper() == Looper.getMainLooper()
266
- DiagnosticLog.perfFrame("screenshot", frameDurationMs, frameNum.toInt(), isMainThread)
292
+ }
293
+ compositeMapboxSnapshot(root, canvas)
294
+ }
295
+
296
+ /**
297
+ * Mapbox MapView uses SurfaceView; decorView.draw() renders it black.
298
+ * Use MapView.snapshot() (Mapbox SDK API) to capture the map and composite it.
299
+ */
300
+ private fun compositeMapboxSnapshot(root: View, canvas: Canvas) {
301
+ val mapView = SpecialCases.shared.getMapboxMapViewForSnapshot(root) ?: return
302
+ try {
303
+ val snapshot = mapView.javaClass.getMethod("snapshot").invoke(mapView)
304
+ val bitmap = snapshot as? Bitmap ?: return
305
+ val loc = IntArray(2)
306
+ mapView.getLocationInWindow(loc)
307
+ canvas.drawBitmap(bitmap, loc[0].toFloat(), loc[1].toFloat(), null)
308
+ bitmap.recycle()
309
+ } catch (e: Exception) {
310
+ DiagnosticLog.trace("[VisualCapture] Mapbox snapshot failed: ${e.message}")
311
+ }
312
+ }
313
+
314
+ private fun findTextureViews(view: View, action: (TextureView) -> Unit) {
315
+ if (view is TextureView && view.isAvailable) {
316
+ action(view)
317
+ }
318
+ if (view is ViewGroup) {
319
+ for (i in 0 until view.childCount) {
320
+ findTextureViews(view.getChildAt(i), action)
267
321
  }
268
-
269
- // Store in buffer
270
- stateLock.withLock {
271
- screenshots.add(Pair(data, captureTs))
272
- enforceScreenshotCaps()
273
- val shouldSend = !deferredUntilCommit && screenshots.size >= batchSize
274
-
275
- if (shouldSend) {
276
- sendScreenshots()
322
+ }
323
+ }
324
+
325
+ private fun processCapture(
326
+ bitmap: Bitmap,
327
+ redactRects: List<Rect>,
328
+ screenScale: Float,
329
+ frameStart: Long,
330
+ force: Boolean
331
+ ) {
332
+ // Apply redactions
333
+ if (redactRects.isNotEmpty()) {
334
+ val canvas = Canvas(bitmap)
335
+ val paint = Paint().apply {
336
+ color = Color.BLACK
337
+ style = Paint.Style.FILL
338
+ }
339
+ for (rect in redactRects) {
340
+ if (rect.width() > 0 && rect.height() > 0) {
341
+ canvas.drawRect(
342
+ rect.left / screenScale,
343
+ rect.top / screenScale,
344
+ rect.right / screenScale,
345
+ rect.bottom / screenScale,
346
+ paint
347
+ )
277
348
  }
278
349
  }
350
+ }
351
+
352
+ // Compress to JPEG
353
+ val stream = ByteArrayOutputStream()
354
+ bitmap.compress(Bitmap.CompressFormat.JPEG, (quality * 100).toInt(), stream)
355
+ bitmap.recycle()
356
+
357
+ val data = stream.toByteArray()
358
+ val captureTs = System.currentTimeMillis()
359
+ val frameNum = frameCounter.incrementAndGet()
360
+
361
+ if (frameNum == 1L) {
362
+ DiagnosticLog.trace("[VisualCapture] First frame captured! size=${data.size} bytes")
363
+ }
364
+ if (frameNum % 30 == 0L) {
365
+ val frameDurationMs = (SystemClock.elapsedRealtime() - frameStart).toDouble()
366
+ val isMainThread = Looper.myLooper() == Looper.getMainLooper()
367
+ DiagnosticLog.perfFrame("screenshot", frameDurationMs, frameNum.toInt(), isMainThread)
368
+ }
369
+
370
+ // Store in buffer
371
+ stateLock.withLock {
372
+ screenshots.add(Pair(data, captureTs))
373
+ enforceScreenshotCaps()
374
+ val shouldSend = !deferredUntilCommit && screenshots.size >= batchSize
279
375
 
280
- } catch (e: Exception) {
281
- DiagnosticLog.fault("Frame capture failed: ${e.message}")
376
+ if (shouldSend) {
377
+ sendScreenshots()
378
+ }
282
379
  }
283
380
  }
284
381
 
@@ -302,7 +399,7 @@ class VisualCapture private constructor(private val context: Context) {
302
399
  return
303
400
  }
304
401
 
305
- DiagnosticLog.notice("[VisualCapture] sendScreenshots: sending ${images.size} frames")
402
+ DiagnosticLog.trace("[VisualCapture] sendScreenshots: sending ${images.size} frames")
306
403
 
307
404
  // All heavy work happens in background
308
405
  encodeExecutor.execute {
@@ -374,12 +471,18 @@ class VisualCapture private constructor(private val context: Context) {
374
471
  sendScreenshots()
375
472
  }
376
473
 
377
- fun uploadPendingFrames(sessionId: String) {
474
+ fun uploadPendingFrames(sessionId: String, completion: ((Boolean) -> Unit)? = null) {
378
475
  val framesPath = File(context.cacheDir, "rj_pending/$sessionId/frames")
379
476
 
380
- if (!framesPath.exists()) return
477
+ if (!framesPath.exists()) {
478
+ completion?.invoke(true)
479
+ return
480
+ }
381
481
 
382
- val frameFiles = framesPath.listFiles()?.sortedBy { it.name } ?: return
482
+ val frameFiles = framesPath.listFiles()?.sortedBy { it.name } ?: run {
483
+ completion?.invoke(true)
484
+ return
485
+ }
383
486
 
384
487
  val frames = mutableListOf<Pair<ByteArray, Long>>()
385
488
  for (file in frameFiles) {
@@ -389,9 +492,15 @@ class VisualCapture private constructor(private val context: Context) {
389
492
  frames.add(Pair(data, ts))
390
493
  }
391
494
 
392
- if (frames.isEmpty()) return
495
+ if (frames.isEmpty()) {
496
+ completion?.invoke(true)
497
+ return
498
+ }
393
499
 
394
- val bundle = packageFrameBundle(frames, frames.first().second) ?: return
500
+ val bundle = packageFrameBundle(frames, frames.first().second) ?: run {
501
+ completion?.invoke(false)
502
+ return
503
+ }
395
504
 
396
505
  SegmentDispatcher.shared.transmitFrameBundle(
397
506
  payload = bundle,
@@ -404,6 +513,7 @@ class VisualCapture private constructor(private val context: Context) {
404
513
  frameFiles.forEach { it.delete() }
405
514
  framesPath.delete()
406
515
  }
516
+ completion?.invoke(ok)
407
517
  }
408
518
  }
409
519
  }
@@ -438,6 +548,10 @@ private class CaptureStateMachine {
438
548
  private class RedactionMask {
439
549
  private val views = CopyOnWriteArrayList<WeakReference<View>>()
440
550
 
551
+ private val cachedAutoRects = mutableListOf<Rect>()
552
+ private var lastScanTime = 0L
553
+ private val scanCacheDurationMs = 500L
554
+
441
555
  fun add(view: View) {
442
556
  views.add(WeakReference(view))
443
557
  }
@@ -446,29 +560,81 @@ private class RedactionMask {
446
560
  views.removeIf { it.get() === view || it.get() == null }
447
561
  }
448
562
 
449
- fun computeRects(): List<Rect> {
563
+ fun invalidateCache() {
564
+ lastScanTime = 0L
565
+ }
566
+
567
+ fun computeRects(decorView: View? = null): List<Rect> {
450
568
  val rects = mutableListOf<Rect>()
451
569
  views.removeIf { it.get() == null }
452
570
 
453
571
  for (ref in views) {
454
572
  val view = ref.get() ?: continue
455
- if (!view.isShown) continue
456
-
457
- val location = IntArray(2)
458
- view.getLocationOnScreen(location)
459
-
460
- val rect = Rect(
461
- location[0],
462
- location[1],
463
- location[0] + view.width,
464
- location[1] + view.height
465
- )
466
-
467
- if (rect.width() > 0 && rect.height() > 0) {
468
- rects.add(rect)
573
+ val rect = getViewRect(view)
574
+ if (rect != null) rects.add(rect)
575
+ }
576
+
577
+ if (decorView != null) {
578
+ val now = SystemClock.elapsedRealtime()
579
+ if (now - lastScanTime >= scanCacheDurationMs) {
580
+ cachedAutoRects.clear()
581
+ scanForSensitiveViews(decorView, cachedAutoRects)
582
+ lastScanTime = now
469
583
  }
584
+ rects.addAll(cachedAutoRects)
470
585
  }
471
586
 
472
587
  return rects
473
588
  }
589
+
590
+ private fun getViewRect(view: View): Rect? {
591
+ if (!view.isShown || view.width <= 0 || view.height <= 0) return null
592
+ val location = IntArray(2)
593
+ view.getLocationOnScreen(location)
594
+ val rect = Rect(
595
+ location[0],
596
+ location[1],
597
+ location[0] + view.width,
598
+ location[1] + view.height
599
+ )
600
+ if (rect.width() > 0 && rect.height() > 0) return rect
601
+ return null
602
+ }
603
+
604
+ private fun scanForSensitiveViews(view: View, rects: MutableList<Rect>, depth: Int = 0) {
605
+ if (depth > 20) return
606
+ if (!view.isShown || view.alpha <= 0.01f || view.width <= 0 || view.height <= 0) return
607
+
608
+ if (shouldMask(view)) {
609
+ val rect = getViewRect(view)
610
+ if (rect != null) {
611
+ rects.add(rect)
612
+ return
613
+ }
614
+ }
615
+
616
+ if (view is ViewGroup) {
617
+ for (i in 0 until view.childCount) {
618
+ scanForSensitiveViews(view.getChildAt(i), rects, depth + 1)
619
+ }
620
+ }
621
+ }
622
+
623
+ private fun shouldMask(view: View): Boolean {
624
+ if (view.contentDescription?.toString() == "rejourney_occlude") return true
625
+
626
+ try {
627
+ val hint = view.getTag(com.facebook.react.R.id.accessibility_hint) as? String
628
+ if (hint == "rejourney_occlude") return true
629
+ } catch (_: Exception) { }
630
+
631
+ if (view is EditText) return true
632
+
633
+ val className = view.javaClass.simpleName.lowercase(java.util.Locale.US)
634
+ if (className.contains("camera") || (className.contains("surfaceview") && className.contains("preview"))) {
635
+ return true
636
+ }
637
+
638
+ return false
639
+ }
474
640
  }
@@ -191,6 +191,20 @@ class RejourneyModule(reactContext: ReactApplicationContext) :
191
191
  instance.getUserIdentity(promise)
192
192
  }
193
193
 
194
+ @ReactMethod
195
+ @DoNotStrip
196
+ override fun setAnonymousId(anonymousId: String, promise: Promise) {
197
+ val instance = getImplOrReject(promise) ?: return
198
+ instance.setAnonymousId(anonymousId, promise)
199
+ }
200
+
201
+ @ReactMethod
202
+ @DoNotStrip
203
+ override fun getAnonymousId(promise: Promise) {
204
+ val instance = getImplOrReject(promise) ?: return
205
+ instance.getAnonymousId(promise)
206
+ }
207
+
194
208
  @ReactMethod
195
209
  @DoNotStrip
196
210
  override fun setDebugMode(enabled: Boolean, promise: Promise) {
@@ -224,6 +224,24 @@ class RejourneyModule(reactContext: ReactApplicationContext) :
224
224
  }
225
225
  }
226
226
 
227
+ @ReactMethod
228
+ fun setAnonymousId(anonymousId: String, promise: Promise) {
229
+ try {
230
+ impl.setAnonymousId(anonymousId, promise)
231
+ } catch (e: Exception) {
232
+ promise.resolve(createErrorMap("Module initialization failed: ${e.message}"))
233
+ }
234
+ }
235
+
236
+ @ReactMethod
237
+ fun getAnonymousId(promise: Promise) {
238
+ try {
239
+ impl.getAnonymousId(promise)
240
+ } catch (e: Exception) {
241
+ promise.resolve(null)
242
+ }
243
+ }
244
+
227
245
  @ReactMethod
228
246
  fun setSDKVersion(version: String) {
229
247
  try {
@@ -37,6 +37,7 @@ public final class DeviceRegistrar: NSObject {
37
37
  // MARK: Private State
38
38
 
39
39
  private let _keychainId = "com.rejourney.device.fingerprint"
40
+ private let _fallbackIdKey = "com.rejourney.device.fallbackId"
40
41
 
41
42
  private lazy var _httpSession: URLSession = {
42
43
  let config = URLSessionConfiguration.default
@@ -124,13 +125,22 @@ public final class DeviceRegistrar: NSObject {
124
125
 
125
126
  var composite = bundleId
126
127
  composite += device.model
127
- composite += device.systemName
128
- composite += device.systemVersion
129
- composite += device.identifierForVendor?.uuidString ?? UUID().uuidString
128
+ composite += device.identifierForVendor?.uuidString ?? _stableDeviceFallback()
130
129
 
131
130
  return _sha256(composite)
132
131
  }
133
132
 
133
+ /// Returns a keychain-persisted UUID so the fingerprint stays stable even when
134
+ /// identifierForVendor is temporarily nil (early boot, App Clips, extensions).
135
+ private func _stableDeviceFallback() -> String {
136
+ if let existing = _keychainLoad(_fallbackIdKey) {
137
+ return existing
138
+ }
139
+ let fresh = UUID().uuidString
140
+ _keychainSave(_fallbackIdKey, value: fresh)
141
+ return fresh
142
+ }
143
+
134
144
  // MARK: Server Communication
135
145
 
136
146
  private func _fetchServerCredential(fingerprint: String, apiToken: String, completion: @escaping (Bool, String?) -> Void) {