@rejourneyco/react-native 1.0.8 → 1.0.10

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 +77 -3
  2. package/android/src/main/AndroidManifest.xml +6 -0
  3. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
  4. package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
  5. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
  6. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  7. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  8. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  9. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
  10. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
  11. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
  12. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  13. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
  14. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  15. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  16. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
  17. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  18. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  19. package/ios/Engine/DeviceRegistrar.swift +13 -3
  20. package/ios/Engine/RejourneyImpl.swift +204 -115
  21. package/ios/Recording/AnrSentinel.swift +58 -25
  22. package/ios/Recording/InteractionRecorder.swift +1 -0
  23. package/ios/Recording/RejourneyURLProtocol.swift +216 -0
  24. package/ios/Recording/ReplayOrchestrator.swift +207 -144
  25. package/ios/Recording/SegmentDispatcher.swift +8 -0
  26. package/ios/Recording/StabilityMonitor.swift +40 -32
  27. package/ios/Recording/TelemetryPipeline.swift +45 -2
  28. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  29. package/ios/Recording/VisualCapture.swift +79 -29
  30. package/ios/Rejourney.mm +27 -8
  31. package/ios/Utility/DataCompression.swift +2 -2
  32. package/ios/Utility/ImageBlur.swift +0 -1
  33. package/lib/commonjs/expoRouterTracking.js +137 -0
  34. package/lib/commonjs/index.js +204 -34
  35. package/lib/commonjs/sdk/autoTracking.js +262 -100
  36. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  37. package/lib/module/expoRouterTracking.js +135 -0
  38. package/lib/module/index.js +203 -28
  39. package/lib/module/sdk/autoTracking.js +260 -100
  40. package/lib/module/sdk/networkInterceptor.js +84 -4
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/expoRouterTracking.d.ts +14 -0
  43. package/lib/typescript/index.d.ts +2 -2
  44. package/lib/typescript/sdk/autoTracking.d.ts +14 -1
  45. package/lib/typescript/types/index.d.ts +56 -5
  46. package/package.json +23 -3
  47. package/src/NativeRejourney.ts +8 -5
  48. package/src/expoRouterTracking.ts +167 -0
  49. package/src/index.ts +221 -35
  50. package/src/sdk/autoTracking.ts +286 -114
  51. package/src/sdk/networkInterceptor.ts +110 -1
  52. package/src/types/index.ts +58 -6
@@ -19,6 +19,7 @@ package com.rejourney.recording
19
19
  import com.rejourney.engine.DiagnosticLog
20
20
  import kotlinx.coroutines.*
21
21
  import okhttp3.*
22
+ import com.rejourney.recording.RejourneyNetworkInterceptor
22
23
  import okhttp3.MediaType.Companion.toMediaType
23
24
  import okhttp3.RequestBody.Companion.toRequestBody
24
25
  import org.json.JSONObject
@@ -127,6 +128,8 @@ class SegmentDispatcher private constructor() {
127
128
  .connectTimeout(5, TimeUnit.SECONDS) // Short timeout for debugging
128
129
  .readTimeout(10, TimeUnit.SECONDS)
129
130
  .writeTimeout(10, TimeUnit.SECONDS)
131
+ // Mirror iOS URLProtocol: ensure native upload/auth traffic is captured
132
+ .addInterceptor(RejourneyNetworkInterceptor())
130
133
  .build()
131
134
 
132
135
  private val retryQueue = mutableListOf<PendingUpload>()
@@ -259,6 +262,8 @@ class SegmentDispatcher private constructor() {
259
262
  backgroundDurationMs: Long,
260
263
  metrics: Map<String, Any>?,
261
264
  currentQueueDepth: Int = 0,
265
+ endReason: String? = null,
266
+ lifecycleVersion: Int? = null,
262
267
  completion: (Boolean) -> Unit
263
268
  ) {
264
269
  val url = "$endpoint/api/ingest/session/end"
@@ -270,6 +275,8 @@ class SegmentDispatcher private constructor() {
270
275
  if (backgroundDurationMs > 0) put("totalBackgroundTimeMs", backgroundDurationMs)
271
276
  metrics?.let { put("metrics", JSONObject(it)) }
272
277
  put("sdkTelemetry", buildSdkTelemetry(currentQueueDepth))
278
+ if (!endReason.isNullOrBlank()) put("endReason", endReason)
279
+ if ((lifecycleVersion ?: 0) > 0) put("lifecycleVersion", lifecycleVersion)
273
280
  }
274
281
 
275
282
  val request = buildRequest(url, body)
@@ -173,6 +173,9 @@ class StabilityMonitor private constructor(private val context: Context) {
173
173
  ReplayOrchestrator.shared?.incrementFaultTally()
174
174
  persistIncident(incident)
175
175
 
176
+ // Flush visual frames to disk for crash safety
177
+ try { VisualCapture.shared?.flushToDisk() } catch (_: Exception) { }
178
+
176
179
  // Give time to write
177
180
  Thread.sleep(150)
178
181
  }
@@ -130,6 +130,32 @@ class TelemetryPipeline private constructor(private val context: Context) {
130
130
  }
131
131
  }
132
132
 
133
+ /**
134
+ * Pause the heartbeat timer when the app goes to background.
135
+ * This prevents the pipeline from uploading empty event batches
136
+ * while backgrounded, which would inflate session duration.
137
+ */
138
+ fun pause() {
139
+ heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
140
+ heartbeatRunnable = null
141
+ }
142
+
143
+ /**
144
+ * Resume the heartbeat timer when the app returns to foreground.
145
+ */
146
+ fun resume() {
147
+ if (heartbeatRunnable != null) return
148
+ mainHandler.post {
149
+ heartbeatRunnable = object : Runnable {
150
+ override fun run() {
151
+ dispatchNow()
152
+ mainHandler.postDelayed(this, 5000)
153
+ }
154
+ }
155
+ mainHandler.postDelayed(heartbeatRunnable!!, 5000)
156
+ }
157
+ }
158
+
133
159
  fun shutdown() {
134
160
  heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
135
161
  heartbeatRunnable = null
@@ -320,6 +346,15 @@ class TelemetryPipeline private constructor(private val context: Context) {
320
346
  ))
321
347
  }
322
348
 
349
+ fun recordConsoleLogEvent(level: String, message: String) {
350
+ enqueue(mapOf(
351
+ "type" to "log",
352
+ "timestamp" to ts(),
353
+ "level" to level,
354
+ "message" to message
355
+ ))
356
+ }
357
+
323
358
  fun recordJSErrorEvent(name: String, message: String, stack: String?) {
324
359
  val event = mutableMapOf<String, Any>(
325
360
  "type" to "error",
@@ -331,6 +366,8 @@ class TelemetryPipeline private constructor(private val context: Context) {
331
366
  event["stack"] = stack
332
367
  }
333
368
  enqueue(event)
369
+ // Prioritize JS error delivery to reduce loss on fatal terminations.
370
+ serialWorker.execute { shipPendingEvents() }
334
371
  }
335
372
 
336
373
  fun recordAnrEvent(durationMs: Long, stack: String?) {
@@ -344,6 +381,8 @@ class TelemetryPipeline private constructor(private val context: Context) {
344
381
  event["stack"] = stack
345
382
  }
346
383
  enqueue(event)
384
+ // Prioritize ANR delivery while the process is still alive.
385
+ serialWorker.execute { shipPendingEvents() }
347
386
  }
348
387
 
349
388
  fun recordUserAssociation(userId: String) {
@@ -203,6 +203,14 @@ class ViewHierarchyScanner private constructor() {
203
203
  }
204
204
 
205
205
  private fun isSensitive(view: View): Boolean {
206
+ if (view.contentDescription?.toString() == "rejourney_occlude") return true
207
+
208
+ try {
209
+ // Check for React Native accessibility hint tag
210
+ val hint = view.getTag(com.facebook.react.R.id.accessibility_hint) as? String
211
+ if (hint == "rejourney_occlude") return true
212
+ } catch (_: Exception) { }
213
+
206
214
  if (view is EditText) {
207
215
  val inputType = view.inputType
208
216
  // Check for password input types
@@ -30,6 +30,7 @@ import android.view.TextureView
30
30
  import android.view.View
31
31
  import android.view.ViewGroup
32
32
  import android.view.WindowManager
33
+ import android.widget.EditText
33
34
  import com.rejourney.engine.DiagnosticLog
34
35
  import com.rejourney.utility.gzipCompress
35
36
  import java.io.ByteArrayOutputStream
@@ -170,6 +171,10 @@ class VisualCapture private constructor(private val context: Context) {
170
171
  redactionMask.remove(view)
171
172
  }
172
173
 
174
+ fun invalidateMaskCache() {
175
+ redactionMask.invalidateCache()
176
+ }
177
+
173
178
  fun configure(snapshotInterval: Double, jpegQuality: Double) {
174
179
  this.snapshotInterval = snapshotInterval
175
180
  this.quality = jpegQuality.toFloat()
@@ -241,7 +246,7 @@ class VisualCapture private constructor(private val context: Context) {
241
246
 
242
247
  if (bounds.width() <= 0 || bounds.height() <= 0) return
243
248
 
244
- val redactRects = redactionMask.computeRects()
249
+ val redactRects = redactionMask.computeRects(decorView)
245
250
 
246
251
  val screenScale = 1.25f
247
252
  val scaledWidth = (bounds.width() / screenScale).toInt()
@@ -466,12 +471,18 @@ class VisualCapture private constructor(private val context: Context) {
466
471
  sendScreenshots()
467
472
  }
468
473
 
469
- fun uploadPendingFrames(sessionId: String) {
474
+ fun uploadPendingFrames(sessionId: String, completion: ((Boolean) -> Unit)? = null) {
470
475
  val framesPath = File(context.cacheDir, "rj_pending/$sessionId/frames")
471
476
 
472
- if (!framesPath.exists()) return
477
+ if (!framesPath.exists()) {
478
+ completion?.invoke(true)
479
+ return
480
+ }
473
481
 
474
- val frameFiles = framesPath.listFiles()?.sortedBy { it.name } ?: return
482
+ val frameFiles = framesPath.listFiles()?.sortedBy { it.name } ?: run {
483
+ completion?.invoke(true)
484
+ return
485
+ }
475
486
 
476
487
  val frames = mutableListOf<Pair<ByteArray, Long>>()
477
488
  for (file in frameFiles) {
@@ -481,9 +492,15 @@ class VisualCapture private constructor(private val context: Context) {
481
492
  frames.add(Pair(data, ts))
482
493
  }
483
494
 
484
- if (frames.isEmpty()) return
495
+ if (frames.isEmpty()) {
496
+ completion?.invoke(true)
497
+ return
498
+ }
485
499
 
486
- val bundle = packageFrameBundle(frames, frames.first().second) ?: return
500
+ val bundle = packageFrameBundle(frames, frames.first().second) ?: run {
501
+ completion?.invoke(false)
502
+ return
503
+ }
487
504
 
488
505
  SegmentDispatcher.shared.transmitFrameBundle(
489
506
  payload = bundle,
@@ -496,6 +513,7 @@ class VisualCapture private constructor(private val context: Context) {
496
513
  frameFiles.forEach { it.delete() }
497
514
  framesPath.delete()
498
515
  }
516
+ completion?.invoke(ok)
499
517
  }
500
518
  }
501
519
  }
@@ -530,6 +548,10 @@ private class CaptureStateMachine {
530
548
  private class RedactionMask {
531
549
  private val views = CopyOnWriteArrayList<WeakReference<View>>()
532
550
 
551
+ private val cachedAutoRects = mutableListOf<Rect>()
552
+ private var lastScanTime = 0L
553
+ private val scanCacheDurationMs = 500L
554
+
533
555
  fun add(view: View) {
534
556
  views.add(WeakReference(view))
535
557
  }
@@ -538,29 +560,81 @@ private class RedactionMask {
538
560
  views.removeIf { it.get() === view || it.get() == null }
539
561
  }
540
562
 
541
- fun computeRects(): List<Rect> {
563
+ fun invalidateCache() {
564
+ lastScanTime = 0L
565
+ }
566
+
567
+ fun computeRects(decorView: View? = null): List<Rect> {
542
568
  val rects = mutableListOf<Rect>()
543
569
  views.removeIf { it.get() == null }
544
570
 
545
571
  for (ref in views) {
546
572
  val view = ref.get() ?: continue
547
- if (!view.isShown) continue
548
-
549
- val location = IntArray(2)
550
- view.getLocationOnScreen(location)
551
-
552
- val rect = Rect(
553
- location[0],
554
- location[1],
555
- location[0] + view.width,
556
- location[1] + view.height
557
- )
558
-
559
- if (rect.width() > 0 && rect.height() > 0) {
560
- 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
561
583
  }
584
+ rects.addAll(cachedAutoRects)
562
585
  }
563
586
 
564
587
  return rects
565
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
+ }
566
640
  }
@@ -17,21 +17,33 @@
17
17
  package com.rejourney.utility
18
18
 
19
19
  import java.io.ByteArrayOutputStream
20
+ import java.io.OutputStream
21
+ import java.util.zip.Deflater
20
22
  import java.util.zip.GZIPOutputStream
21
23
 
22
24
  /**
23
25
  * Data compression utilities
24
26
  * Android implementation aligned with iOS DataCompression.swift
27
+ * Uses level 9 (BEST_COMPRESSION) for smaller S3 payloads.
25
28
  */
26
29
  object DataCompression {
30
+
31
+ /**
32
+ * GZIPOutputStream that uses Deflater.BEST_COMPRESSION for maximum ratio.
33
+ */
34
+ private class GzipLevel9OutputStream(out: OutputStream) : GZIPOutputStream(out) {
35
+ init {
36
+ def.setLevel(Deflater.BEST_COMPRESSION)
37
+ }
38
+ }
27
39
 
28
40
  /**
29
- * Compress data using gzip
41
+ * Compress data using gzip (level 9)
30
42
  */
31
43
  fun gzipCompress(data: ByteArray): ByteArray? {
32
44
  return try {
33
45
  val bos = ByteArrayOutputStream()
34
- GZIPOutputStream(bos).use { gzip ->
46
+ GzipLevel9OutputStream(bos).use { gzip ->
35
47
  gzip.write(data)
36
48
  }
37
49
  bos.toByteArray()
@@ -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) {