@rejourneyco/react-native 1.0.0 → 1.0.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 ADDED
@@ -0,0 +1,29 @@
1
+ # @rejourneyco/react-native
2
+
3
+ Lightweight session replay and observability SDK for React Native. Pixel-perfect video capture with real-time incident detection.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @rejourneyco/react-native
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { initRejourney, startRejourney } from '@rejourneyco/react-native';
15
+
16
+ // Initialize with your public key
17
+ initRejourney('pk_live_xxxxxxxxxxxx');
18
+
19
+ // Start recording after obtaining user consent
20
+ startRejourney();
21
+ ```
22
+
23
+ ## Documentation
24
+
25
+ Full integration guides and API reference: https://rejourney.co/docs/reactnative/overview
26
+
27
+ ## License
28
+
29
+ Licensed under Apache 2.0
@@ -1005,7 +1005,14 @@ class RejourneyModuleImpl(
1005
1005
  try {
1006
1006
  val safeUserId = userId.ifEmpty { "anonymous" }
1007
1007
 
1008
- // Update userId
1008
+ // KEY CHANGE: Persist directly to SharedPreferences (Native Storage)
1009
+ // This replaces the need for async-storage on the JS side
1010
+ reactContext.getSharedPreferences("rejourney", 0)
1011
+ .edit()
1012
+ .putString("rj_user_identity", safeUserId)
1013
+ .apply()
1014
+
1015
+ // Update in-memory state
1009
1016
  this.userId = safeUserId
1010
1017
 
1011
1018
  // Update upload manager
@@ -1030,6 +1037,10 @@ class RejourneyModuleImpl(
1030
1037
  }
1031
1038
  }
1032
1039
 
1040
+ fun getUserIdentity(promise: Promise) {
1041
+ promise.resolve(userId)
1042
+ }
1043
+
1033
1044
  // ==================== Helper Methods ====================
1034
1045
 
1035
1046
  private fun createResultMap(success: Boolean, sessionId: String, error: String? = null): WritableMap {
@@ -178,6 +178,7 @@ class CaptureEngine(private val context: Context) : VideoEncoderDelegate {
178
178
  private val isShuttingDown = AtomicBoolean(false)
179
179
  private var _isRecording: Boolean = false
180
180
  private val captureInProgress = AtomicBoolean(false)
181
+ private val isWarmingUp = AtomicBoolean(false)
181
182
  private var sessionId: String? = null
182
183
  private var currentScreenName: String? = null
183
184
  private var viewScanner: ViewHierarchyScanner? = null
@@ -407,6 +408,23 @@ class CaptureEngine(private val context: Context) : VideoEncoderDelegate {
407
408
 
408
409
  Logger.info("[CaptureEngine] Resuming video capture")
409
410
 
411
+ // DEFENSIVE FIX: Warmup period
412
+ // When returning from background, the view hierarchy and layout may not be stable immediately.
413
+ // This is primarily an iOS issue but we apply it here for consistency and safety.
414
+ isWarmingUp.set(true)
415
+ Logger.debug("[CaptureEngine] Warmup started (200ms)")
416
+
417
+ mainHandler.postDelayed({
418
+ if (isShuttingDown.get()) return@postDelayed
419
+ isWarmingUp.set(false)
420
+ Logger.debug("[CaptureEngine] Warmup complete")
421
+
422
+ // Trigger immediate capture check
423
+ if (_isRecording) {
424
+ requestCapture(CaptureImportance.MEDIUM, "warmup_complete", forceCapture = false)
425
+ }
426
+ }, 200)
427
+
410
428
  captureHeuristics.reset()
411
429
  resetCachedFrames()
412
430
  pendingCapture = null
@@ -594,6 +612,11 @@ class CaptureEngine(private val context: Context) : VideoEncoderDelegate {
594
612
  }
595
613
  private fun requestCapture(importance: CaptureImportance, reason: String, forceCapture: Boolean) {
596
614
  if (!_isRecording || isShuttingDown.get()) return
615
+
616
+ if (isWarmingUp.get()) {
617
+ return
618
+ }
619
+
597
620
  if (!forceCapture && !shouldCapture(importance)) {
598
621
  Logger.debug("[CaptureEngine] Capture throttled: $reason (importance: $importance)")
599
622
  return
@@ -687,7 +710,8 @@ class CaptureEngine(private val context: Context) : VideoEncoderDelegate {
687
710
  val decision = captureHeuristics.decisionForSignature(
688
711
  pending.layoutSignature,
689
712
  now,
690
- hasLastFrame = lastCapturedBitmap != null
713
+ hasLastFrame = lastCapturedBitmap != null,
714
+ importance = pending.importance
691
715
  )
692
716
 
693
717
  if (decision.action == CaptureAction.Defer) {
@@ -1,5 +1,7 @@
1
1
  package com.rejourney.capture
2
2
 
3
+ import com.rejourney.core.CaptureImportance
4
+
3
5
 
4
6
  enum class CaptureAction {
5
7
  RenderNow,
@@ -216,53 +218,89 @@ class CaptureHeuristics {
216
218
  }
217
219
  }
218
220
 
219
- fun decisionForSignature(signature: String?, nowMs: Long, hasLastFrame: Boolean): CaptureDecision {
221
+ fun decisionForSignature(signature: String?, nowMs: Long, hasLastFrame: Boolean, importance: CaptureImportance): CaptureDecision {
220
222
  var earliestSafeTime = nowMs
221
223
  var blockerReason = CaptureReason.RenderNow
222
224
 
223
- considerBlockerSince(lastTouchTime, QUIET_TOUCH_MS, nowMs, earliestSafeTime)?.let {
224
- earliestSafeTime = it
225
- blockerReason = CaptureReason.DeferTouch
225
+ // Check importance to potentially bypass heuristics
226
+ val isUrgent = importance == CaptureImportance.HIGH || importance == CaptureImportance.CRITICAL
227
+
228
+ // Touch - Usually want smooth input, but CRITICAL updates (like navigation) take precedence
229
+ if (!isUrgent) {
230
+ considerBlockerSince(lastTouchTime, QUIET_TOUCH_MS, nowMs, earliestSafeTime)?.let {
231
+ earliestSafeTime = it
232
+ blockerReason = CaptureReason.DeferTouch
233
+ }
226
234
  }
227
- considerBlockerSince(lastScrollTime, QUIET_SCROLL_MS, nowMs, earliestSafeTime)?.let {
228
- earliestSafeTime = it
229
- blockerReason = CaptureReason.DeferScroll
235
+
236
+ // Scroll - High jank risk
237
+ // Even urgent captures should respect scroll to avoid visible hitching, unless CRITICAL
238
+ if (importance != CaptureImportance.CRITICAL) {
239
+ considerBlockerSince(lastScrollTime, QUIET_SCROLL_MS, nowMs, earliestSafeTime)?.let {
240
+ earliestSafeTime = it
241
+ blockerReason = CaptureReason.DeferScroll
242
+ }
230
243
  }
231
- considerBlockerSince(lastBounceTime, QUIET_BOUNCE_MS, nowMs, earliestSafeTime)?.let {
232
- earliestSafeTime = it
233
- blockerReason = CaptureReason.DeferBounce
244
+
245
+ // Bounce/Rubber-banding
246
+ if (!isUrgent) {
247
+ considerBlockerSince(lastBounceTime, QUIET_BOUNCE_MS, nowMs, earliestSafeTime)?.let {
248
+ earliestSafeTime = it
249
+ blockerReason = CaptureReason.DeferBounce
250
+ }
234
251
  }
235
- considerBlockerSince(lastRefreshTime, QUIET_REFRESH_MS, nowMs, earliestSafeTime)?.let {
236
- earliestSafeTime = it
237
- blockerReason = CaptureReason.DeferRefresh
252
+
253
+ // Refresh
254
+ if (!isUrgent) {
255
+ considerBlockerSince(lastRefreshTime, QUIET_REFRESH_MS, nowMs, earliestSafeTime)?.let {
256
+ earliestSafeTime = it
257
+ blockerReason = CaptureReason.DeferRefresh
258
+ }
238
259
  }
239
- considerBlockerSince(lastTransitionTime, QUIET_TRANSITION_MS, nowMs, earliestSafeTime)?.let {
240
- earliestSafeTime = it
241
- blockerReason = CaptureReason.DeferTransition
260
+
261
+ // Transition - KEY FIX: Urgent captures (NAVIGATION) must bypass this!
262
+ if (!isUrgent) {
263
+ considerBlockerSince(lastTransitionTime, QUIET_TRANSITION_MS, nowMs, earliestSafeTime)?.let {
264
+ earliestSafeTime = it
265
+ blockerReason = CaptureReason.DeferTransition
266
+ }
242
267
  }
243
268
 
244
269
  if (keyboardAnimating) {
245
270
  lastKeyboardTime = nowMs
246
271
  }
247
- considerBlockerSince(lastKeyboardTime, QUIET_KEYBOARD_MS, nowMs, earliestSafeTime)?.let {
248
- earliestSafeTime = it
249
- blockerReason = CaptureReason.DeferKeyboard
272
+ // Keyboard animations can be jerky
273
+ if (!isUrgent) {
274
+ considerBlockerSince(lastKeyboardTime, QUIET_KEYBOARD_MS, nowMs, earliestSafeTime)?.let {
275
+ earliestSafeTime = it
276
+ blockerReason = CaptureReason.DeferKeyboard
277
+ }
250
278
  }
251
279
 
252
- considerBlockerSince(lastMapTime, QUIET_MAP_MS, nowMs, earliestSafeTime)?.let {
253
- earliestSafeTime = it
254
- blockerReason = CaptureReason.DeferMap
255
- }
280
+ // Map - Always defer map motion as it's very expensive and glitchy
281
+ // Maps are special; even CRITICAL captures might want to wait for map settle if possible,
282
+ // but we'll allow CRITICAL to force it if absolutely needed.
283
+ if (importance != CaptureImportance.CRITICAL) {
284
+ considerBlockerSince(lastMapTime, QUIET_MAP_MS, nowMs, earliestSafeTime)?.let {
285
+ earliestSafeTime = it
286
+ blockerReason = CaptureReason.DeferMap
287
+ }
256
288
 
257
- if (mapSettleUntilMs > nowMs && mapSettleUntilMs > earliestSafeTime) {
258
- earliestSafeTime = mapSettleUntilMs
259
- blockerReason = CaptureReason.DeferMap
289
+ if (mapSettleUntilMs > nowMs && mapSettleUntilMs > earliestSafeTime) {
290
+ earliestSafeTime = mapSettleUntilMs
291
+ blockerReason = CaptureReason.DeferMap
292
+ }
260
293
  }
261
294
 
262
295
  if (animationBlocking) {
263
- considerBlockerSince(lastAnimationTime, QUIET_ANIMATION_MS, nowMs, earliestSafeTime)?.let {
264
- earliestSafeTime = it
265
- blockerReason = CaptureReason.DeferBigAnimation
296
+ // Big animations (Lottie etc).
297
+ // If urgent, we might want to capture the final state of an animation or screen change
298
+ // regardless of the animation loop.
299
+ if (!isUrgent) {
300
+ considerBlockerSince(lastAnimationTime, QUIET_ANIMATION_MS, nowMs, earliestSafeTime)?.let {
301
+ earliestSafeTime = it
302
+ blockerReason = CaptureReason.DeferBigAnimation
303
+ }
266
304
  }
267
305
  }
268
306
 
@@ -278,11 +316,11 @@ class CaptureHeuristics {
278
316
  val staleOnly = stale && hasLastFrame && !signatureChanged && !keyframeDue
279
317
  val suppressStaleRender = staleOnly && (hasVideoSurface || hasWebSurface || hasCameraSurface)
280
318
 
281
- if (suppressStaleRender) {
319
+ if (suppressStaleRender && !isUrgent) {
282
320
  return CaptureDecision(CaptureAction.ReuseLast, CaptureReason.ReuseSignatureUnchanged)
283
321
  }
284
322
 
285
- if (!hasLastFrame || signatureChanged || stale || keyframeDue) {
323
+ if (!hasLastFrame || signatureChanged || stale || keyframeDue || isUrgent) {
286
324
  return CaptureDecision(CaptureAction.RenderNow, CaptureReason.RenderNow)
287
325
  }
288
326
 
@@ -351,7 +389,7 @@ class CaptureHeuristics {
351
389
  private const val QUIET_BOUNCE_MS = 200L
352
390
  private const val QUIET_REFRESH_MS = 220L
353
391
  private const val QUIET_MAP_MS = 550L
354
- private const val QUIET_TRANSITION_MS = 200L
392
+ private const val QUIET_TRANSITION_MS = 100L
355
393
  private const val QUIET_KEYBOARD_MS = 250L
356
394
  private const val QUIET_ANIMATION_MS = 250L
357
395
 
@@ -11,11 +11,11 @@ object Constants {
11
11
  // Video Capture Configuration (H.264 Segment Mode)
12
12
  // These match iOS RJCaptureEngine defaults for performance
13
13
 
14
- /** Default capture scale factor (0.35 = 35% of original size) - matches iOS */
15
- const val DEFAULT_CAPTURE_SCALE = 0.35f
14
+ /** Default capture scale factor (0.25 = 25% of original size) - matches iOS */
15
+ const val DEFAULT_CAPTURE_SCALE = 0.25f
16
16
 
17
- /** Target video bitrate in bits per second (1.5 Mbps) - matches iOS */
18
- const val DEFAULT_VIDEO_BITRATE = 1_500_000
17
+ /** Target video bitrate in bits per second (1.0 Mbps) - matches iOS */
18
+ const val DEFAULT_VIDEO_BITRATE = 1_000_000
19
19
 
20
20
  /** Maximum video dimension in pixels (longest edge) - matches iOS */
21
21
  const val MAX_VIDEO_DIMENSION = 1920
@@ -171,6 +171,13 @@ class RejourneyModule(reactContext: ReactApplicationContext) :
171
171
  instance.setUserIdentity(userId, promise)
172
172
  }
173
173
 
174
+ @ReactMethod
175
+ @DoNotStrip
176
+ override fun getUserIdentity(promise: Promise) {
177
+ val instance = getImplOrReject(promise) ?: return
178
+ instance.getUserIdentity(promise)
179
+ }
180
+
174
181
  @ReactMethod
175
182
  @DoNotStrip
176
183
  override fun setDebugMode(enabled: Boolean, promise: Promise) {
@@ -61,6 +61,7 @@ typedef struct {
61
61
  @property(nonatomic, assign) NSInteger generation;
62
62
  @property(nonatomic, strong, nullable) RJViewHierarchyScanResult *scanResult;
63
63
  @property(nonatomic, copy, nullable) NSString *layoutSignature;
64
+ @property(nonatomic, assign) RJCaptureImportance importance;
64
65
 
65
66
  @end
66
67
 
@@ -168,6 +169,8 @@ typedef struct {
168
169
  @property(nonatomic, assign) CFRunLoopObserverRef runLoopObserver;
169
170
  @property(nonatomic, assign) BOOL runLoopCapturePending;
170
171
 
172
+ @property(nonatomic, assign) BOOL isWarmingUp;
173
+
171
174
  @end
172
175
 
173
176
  #pragma mark - Implementation
@@ -300,6 +303,7 @@ typedef struct {
300
303
  _isShuttingDown = NO;
301
304
  _uiReadyForCapture = NO;
302
305
  _framesSinceHierarchy = 0;
306
+ _isWarmingUp = NO;
303
307
 
304
308
  [self applyDefaultConfiguration];
305
309
 
@@ -494,7 +498,38 @@ typedef struct {
494
498
 
495
499
  - (void)appDidBecomeActive:(NSNotification *)notification {
496
500
  self.isInBackground = NO;
497
- RJLogDebug(@"CaptureEngine: App became active - warmup before capture");
501
+
502
+ // DEFENSIVE FIX: Warmup period
503
+ // When returning from background, the view hierarchy and layout may not be
504
+ // stable immediately. This can cause privacy masks to be drawn in the wrong
505
+ // position relative to the content (race condition). We impose a short
506
+ // "warmup" period where we skip capture to allow AutoLayout to settle.
507
+ self.isWarmingUp = YES;
508
+
509
+ // Clear stale caches to force fresh scan
510
+ if (self.lastCapturedPixelBuffer) {
511
+ CVPixelBufferRelease(self.lastCapturedPixelBuffer);
512
+ self.lastCapturedPixelBuffer = NULL;
513
+ }
514
+ self.lastMaskScanResult = nil;
515
+ self.lastSafeMaskScanResult = nil;
516
+
517
+ RJLogDebug(@"CaptureEngine: App became active - starting warmup (0.2s)");
518
+
519
+ __weak typeof(self) weakSelf = self;
520
+ dispatch_after(
521
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)),
522
+ dispatch_get_main_queue(), ^{
523
+ __strong typeof(weakSelf) strongSelf = weakSelf;
524
+ if (!strongSelf)
525
+ return;
526
+
527
+ strongSelf.isWarmingUp = NO;
528
+ RJLogDebug(@"CaptureEngine: Warmup complete - resuming capture");
529
+
530
+ // Trigger an immediate capture check
531
+ [strongSelf captureVideoFrame];
532
+ });
498
533
  }
499
534
 
500
535
  #pragma mark - System Monitoring
@@ -1087,14 +1122,31 @@ typedef struct {
1087
1122
  }
1088
1123
 
1089
1124
  - (void)captureVideoFrame {
1125
+ [self captureVideoFrameWithImportance:RJCaptureImportanceLow reason:@"timer"];
1126
+ }
1127
+
1128
+ - (void)captureVideoFrameWithImportance:(RJCaptureImportance)importance
1129
+ reason:(NSString *)reason {
1090
1130
  if (!self.internalIsRecording || self.isShuttingDown)
1091
1131
  return;
1132
+
1133
+ if (self.isWarmingUp) {
1134
+ return;
1135
+ }
1136
+
1137
+ // Critical events (like navigation) should bypass UI ready checks if
1138
+ // possible, but we still need a valid UI.
1092
1139
  if (!self.uiReadyForCapture) {
1093
1140
  if (self.framesSinceSessionStart % 60 == 0)
1094
1141
  RJLogDebug(@"Skipping capture: UI not ready");
1095
1142
  return;
1096
1143
  }
1097
- if (self.internalPerformanceLevel == RJPerformanceLevelPaused) {
1144
+
1145
+ // Critical events override paused state
1146
+ BOOL isCritical = (importance == RJCaptureImportanceCritical ||
1147
+ importance == RJCaptureImportanceHigh);
1148
+ if (self.internalPerformanceLevel == RJPerformanceLevelPaused &&
1149
+ !isCritical) {
1098
1150
  static NSInteger pauseSkipCount = 0;
1099
1151
  if (++pauseSkipCount % 60 == 0)
1100
1152
  RJLogDebug(@"Skipping capture: Performance Paused");
@@ -1103,18 +1155,30 @@ typedef struct {
1103
1155
  NSTimeInterval now = CACurrentMediaTime();
1104
1156
 
1105
1157
  if (self.pendingCapture) {
1158
+ // If we have a pending capture, force it to resolve now.
1159
+ // If the new request is High importance, we effectively "upgrade" the
1160
+ // current cycle by ensuring it runs immediately.
1106
1161
  self.pendingCapture.deadline = now;
1107
1162
  [self attemptPendingCapture:self.pendingCapture fullScan:NO];
1108
1163
  }
1109
1164
 
1110
1165
  RJCapturePendingCapture *pending = [[RJCapturePendingCapture alloc] init];
1111
1166
  pending.wantedAt = now;
1167
+ pending.importance = importance;
1168
+
1112
1169
  NSTimeInterval grace = self.captureHeuristics.captureGraceSeconds;
1113
1170
  if (self.captureHeuristics.animationBlocking ||
1114
1171
  self.captureHeuristics.scrollActive ||
1115
1172
  self.captureHeuristics.keyboardAnimating) {
1116
1173
  grace = MIN(grace, 0.3);
1117
1174
  }
1175
+
1176
+ // High importance requests should have a shorter grace, forcing faster
1177
+ // resolution
1178
+ if (isCritical) {
1179
+ grace = MIN(grace, 0.1);
1180
+ }
1181
+
1118
1182
  pending.deadline = now + grace;
1119
1183
  pending.timestamp = [self currentTimestamp];
1120
1184
  pending.generation = ++self.pendingCaptureGeneration;
@@ -1207,7 +1271,8 @@ typedef struct {
1207
1271
  RJCaptureHeuristicsDecision *decision = [self.captureHeuristics
1208
1272
  decisionForSignature:pending.layoutSignature
1209
1273
  now:now
1210
- hasLastFrame:(self.lastCapturedPixelBuffer != NULL)];
1274
+ hasLastFrame:(self.lastCapturedPixelBuffer != NULL)
1275
+ importance:pending.importance];
1211
1276
 
1212
1277
  if (decision.action == RJCaptureHeuristicsActionRenderNow && !fullScan) {
1213
1278
  RJ_TIME_START_NAMED(viewScan);
@@ -1241,7 +1306,8 @@ typedef struct {
1241
1306
  decision = [self.captureHeuristics
1242
1307
  decisionForSignature:pending.layoutSignature
1243
1308
  now:now
1244
- hasLastFrame:(self.lastCapturedPixelBuffer != NULL)];
1309
+ hasLastFrame:(self.lastCapturedPixelBuffer != NULL)
1310
+ importance:pending.importance];
1245
1311
  }
1246
1312
 
1247
1313
  if (decision.action == RJCaptureHeuristicsActionDefer && fullScan) {
@@ -1342,7 +1408,10 @@ typedef struct {
1342
1408
  }
1343
1409
  self.pendingDefensiveCaptureTime = 0;
1344
1410
  self.lastIntentTime = CACurrentMediaTime();
1345
- [self captureVideoFrame];
1411
+ // Defensive capture triggered by heuristics (e.g.
1412
+ // navigation) is High importance
1413
+ [self captureVideoFrameWithImportance:RJCaptureImportanceHigh
1414
+ reason:reason];
1346
1415
  });
1347
1416
  }
1348
1417
 
@@ -10,6 +10,7 @@
10
10
  #import <Foundation/Foundation.h>
11
11
  #import <UIKit/UIKit.h>
12
12
 
13
+ #import "../Core/RJTypes.h"
13
14
  #import "RJViewHierarchyScanner.h"
14
15
 
15
16
  NS_ASSUME_NONNULL_BEGIN
@@ -59,7 +60,7 @@ typedef NS_ENUM(NSInteger, RJCaptureHeuristicsReason) {
59
60
  - (void)recordMapInteractionAtTime:(NSTimeInterval)time;
60
61
  - (void)recordNavigationEventAtTime:(NSTimeInterval)time;
61
62
  - (void)recordRenderedSignature:(nullable NSString *)signature
62
- atTime:(NSTimeInterval)time;
63
+ atTime:(NSTimeInterval)time;
63
64
 
64
65
  - (void)updateWithScanResult:(RJViewHierarchyScanResult *)scanResult
65
66
  window:(UIWindow *)window
@@ -68,10 +69,11 @@ typedef NS_ENUM(NSInteger, RJCaptureHeuristicsReason) {
68
69
  - (void)updateWithStabilityProbeForWindow:(UIWindow *)window
69
70
  now:(NSTimeInterval)now;
70
71
 
71
- - (RJCaptureHeuristicsDecision *)decisionForSignature:
72
- (nullable NSString *)signature
73
- now:(NSTimeInterval)now
74
- hasLastFrame:(BOOL)hasLastFrame;
72
+ - (RJCaptureHeuristicsDecision *)
73
+ decisionForSignature:(nullable NSString *)signature
74
+ now:(NSTimeInterval)now
75
+ hasLastFrame:(BOOL)hasLastFrame
76
+ importance:(RJCaptureImportance)importance;
75
77
 
76
78
  + (NSString *)stringForReason:(RJCaptureHeuristicsReason)reason;
77
79