@rejourneyco/react-native 1.0.2 → 1.0.3

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 (39) hide show
  1. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +38 -363
  2. package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +11 -113
  3. package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +1 -15
  4. package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +1 -61
  5. package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +3 -1
  6. package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +1 -22
  7. package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +3 -26
  8. package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +0 -2
  9. package/android/src/main/java/com/rejourney/network/UploadManager.kt +7 -93
  10. package/android/src/main/java/com/rejourney/network/UploadWorker.kt +5 -41
  11. package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +2 -58
  12. package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +4 -4
  13. package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +36 -7
  14. package/ios/Capture/RJViewHierarchyScanner.m +68 -51
  15. package/ios/Core/RJLifecycleManager.m +0 -14
  16. package/ios/Core/Rejourney.mm +24 -37
  17. package/ios/Network/RJDeviceAuthManager.m +0 -2
  18. package/ios/Network/RJUploadManager.h +8 -0
  19. package/ios/Network/RJUploadManager.m +45 -0
  20. package/ios/Privacy/RJPrivacyMask.m +5 -31
  21. package/ios/Rejourney.h +0 -14
  22. package/ios/Touch/RJTouchInterceptor.m +21 -15
  23. package/ios/Utils/RJEventBuffer.m +57 -69
  24. package/ios/Utils/RJWindowUtils.m +87 -86
  25. package/lib/commonjs/index.js +42 -30
  26. package/lib/commonjs/sdk/autoTracking.js +0 -3
  27. package/lib/commonjs/sdk/networkInterceptor.js +0 -11
  28. package/lib/commonjs/sdk/utils.js +73 -14
  29. package/lib/module/index.js +42 -30
  30. package/lib/module/sdk/autoTracking.js +0 -3
  31. package/lib/module/sdk/networkInterceptor.js +0 -11
  32. package/lib/module/sdk/utils.js +73 -14
  33. package/lib/typescript/sdk/utils.d.ts +31 -1
  34. package/package.json +16 -4
  35. package/src/index.ts +40 -19
  36. package/src/sdk/autoTracking.ts +0 -2
  37. package/src/sdk/constants.ts +13 -13
  38. package/src/sdk/networkInterceptor.ts +0 -9
  39. package/src/sdk/utils.ts +76 -14
@@ -152,8 +152,10 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
152
152
  @property(nonatomic, strong) NSMutableArray<NSValue *> *mutableVideoFrames;
153
153
  @property(nonatomic, strong) NSMutableArray<NSValue *> *mutableMapViewFrames;
154
154
  @property(nonatomic, strong) NSMutableArray<NSValue *> *mutableMapViewPointers;
155
- @property(nonatomic, strong) NSMutableArray<NSValue *> *mutableScrollViewPointers;
156
- @property(nonatomic, strong) NSMutableArray<NSValue *> *mutableAnimatedViewPointers;
155
+ @property(nonatomic, strong)
156
+ NSMutableArray<NSValue *> *mutableScrollViewPointers;
157
+ @property(nonatomic, strong)
158
+ NSMutableArray<NSValue *> *mutableAnimatedViewPointers;
157
159
 
158
160
  @property(nonatomic, strong) NSMapTable<Class, NSString *> *classNameCache;
159
161
 
@@ -251,11 +253,14 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
251
253
  // because map tiles load asynchronously and layout signature doesn't
252
254
  // capture them
253
255
  _mapViewClasses = [NSSet setWithArray:@[
254
- @"MKMapView", // Apple Maps
255
- @"AIRMap", // react-native-maps (iOS)
256
- @"AIRMapView", // react-native-maps alternate
257
- @"RNMMapView", // react-native-maps newer versions
258
- @"GMSMapView", // Google Maps SDK
256
+ @"MKMapView", // Apple Maps
257
+ @"AIRMap", // react-native-maps (iOS)
258
+ @"AIRMapView", // react-native-maps alternate
259
+ @"RNMMapView", // react-native-maps newer versions
260
+ @"GMSMapView", // Google Maps SDK
261
+ @"MGLMapView", // Mapbox GL Native (< v10)
262
+ @"RCTMGLMapView", // React Native Mapbox wrapper
263
+ @"MapboxMapView", // Mapbox Maps SDK (v10+)
259
264
  ]];
260
265
 
261
266
  _layoutSignatureHash = 14695981039346656037ULL;
@@ -331,7 +336,8 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
331
336
  BOOL needsPrivacyFallback =
332
337
  (self.config.detectTextInputs &&
333
338
  self.mutableTextInputFrames.count == 0) ||
334
- (self.config.detectCameraViews && self.mutableCameraFrames.count == 0) ||
339
+ (self.config.detectCameraViews &&
340
+ self.mutableCameraFrames.count == 0) ||
335
341
  (self.config.detectWebViews && self.mutableWebViewFrames.count == 0) ||
336
342
  (self.config.detectVideoLayers && self.mutableVideoFrames.count == 0);
337
343
  if (needsPrivacyFallback && (hitViewLimit || self.didBailOutEarly)) {
@@ -358,9 +364,7 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
358
364
  result.hasAnyAnimations = self.scanHasAnimations;
359
365
  CGFloat screenArea = window.bounds.size.width * window.bounds.size.height;
360
366
  result.animationAreaRatio =
361
- (screenArea > 0)
362
- ? MIN(self.scanAnimatedArea / screenArea, 1.0)
363
- : 0.0;
367
+ (screenArea > 0) ? MIN(self.scanAnimatedArea / screenArea, 1.0) : 0.0;
364
368
  result.didBailOutEarly = self.didBailOutEarly;
365
369
 
366
370
  if (self.layoutSignatureHash != 14695981039346656037ULL) {
@@ -498,9 +502,9 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
498
502
  self.mutableTextInputFrames.count == 0) ||
499
503
  (self.config.detectCameraViews &&
500
504
  self.mutableCameraFrames.count == 0) ||
501
- (self.config.detectWebViews && self.mutableWebViewFrames.count == 0) ||
502
- (self.config.detectVideoLayers &&
503
- self.mutableVideoFrames.count == 0);
505
+ (self.config.detectWebViews &&
506
+ self.mutableWebViewFrames.count == 0) ||
507
+ (self.config.detectVideoLayers && self.mutableVideoFrames.count == 0);
504
508
  if (needsPrivacyFallback && hitViewLimit) {
505
509
  [self scanSensitiveViewsOnlyInWindow:window];
506
510
  }
@@ -511,7 +515,8 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
511
515
  BOOL needsPrivacyFallback =
512
516
  (self.config.detectTextInputs &&
513
517
  self.mutableTextInputFrames.count == 0) ||
514
- (self.config.detectCameraViews && self.mutableCameraFrames.count == 0) ||
518
+ (self.config.detectCameraViews &&
519
+ self.mutableCameraFrames.count == 0) ||
515
520
  (self.config.detectWebViews && self.mutableWebViewFrames.count == 0) ||
516
521
  (self.config.detectVideoLayers && self.mutableVideoFrames.count == 0);
517
522
  if (needsPrivacyFallback && self.didBailOutEarly) {
@@ -542,9 +547,7 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
542
547
  CGFloat screenArea =
543
548
  primaryWindow.bounds.size.width * primaryWindow.bounds.size.height;
544
549
  result.animationAreaRatio =
545
- (screenArea > 0)
546
- ? MIN(self.scanAnimatedArea / screenArea, 1.0)
547
- : 0.0;
550
+ (screenArea > 0) ? MIN(self.scanAnimatedArea / screenArea, 1.0) : 0.0;
548
551
  result.didBailOutEarly = self.didBailOutEarly;
549
552
 
550
553
  if (self.layoutSignatureHash != 14695981039346656037ULL) {
@@ -679,8 +682,10 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
679
682
 
680
683
  @try {
681
684
  BOOL isWebView = self.config.detectWebViews && [self isWebView:view];
682
- BOOL isCamera = self.config.detectCameraViews && [self isCameraPreview:view];
683
- BOOL isVideo = self.config.detectVideoLayers && [self isVideoLayerView:view];
685
+ BOOL isCamera =
686
+ self.config.detectCameraViews && [self isCameraPreview:view];
687
+ BOOL isVideo =
688
+ self.config.detectVideoLayers && [self isVideoLayerView:view];
684
689
  BOOL isBlockedSurface = isWebView || isCamera || isVideo;
685
690
 
686
691
  [self checkSensitiveView:view inWindow:window];
@@ -762,10 +767,11 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
762
767
  UIEdgeInsets inset = ((UIScrollView *)view).contentInset;
763
768
  [self mixInt:(int32_t)lrintf(isfinite(inset.top) ? inset.top * 100 : 0)];
764
769
  [self mixInt:(int32_t)lrintf(isfinite(inset.bottom) ? inset.bottom * 100
765
- : 0)];
766
- [self mixInt:(int32_t)lrintf(isfinite(inset.left) ? inset.left * 100 : 0)];
770
+ : 0)];
771
+ [self
772
+ mixInt:(int32_t)lrintf(isfinite(inset.left) ? inset.left * 100 : 0)];
767
773
  [self mixInt:(int32_t)lrintf(isfinite(inset.right) ? inset.right * 100
768
- : 0)];
774
+ : 0)];
769
775
  }
770
776
 
771
777
  // 5. Mix Text Content (avoid input content; use length only)
@@ -840,7 +846,8 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
840
846
  }
841
847
  }
842
848
 
843
- - (void)appendBlockedSurfaceInfoToSignature:(UIView *)view depth:(NSInteger)depth {
849
+ - (void)appendBlockedSurfaceInfoToSignature:(UIView *)view
850
+ depth:(NSInteger)depth {
844
851
  if (!view) {
845
852
  return;
846
853
  }
@@ -998,18 +1005,18 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
998
1005
  }
999
1006
 
1000
1007
  #ifdef DEBUG
1001
- RJLogDebug(@"ViewHierarchyScanner: Found %@ at (%.0f,%.0f,%.0f,%.0f) - "
1002
- @"view.window=%@ targetWindow=%@",
1003
- isTextInput
1004
- ? @"TextInput"
1005
- : (isCamera
1006
- ? @"Camera"
1007
- : (isWebView ? @"WebView"
1008
- : (isVideo ? @"Video" : @"MaskedView"))),
1009
- sanitizedFrame.origin.x, sanitizedFrame.origin.y,
1010
- sanitizedFrame.size.width, sanitizedFrame.size.height,
1011
- NSStringFromClass([view.window class]),
1012
- NSStringFromClass([targetWindow class]));
1008
+ RJLogDebug(
1009
+ @"ViewHierarchyScanner: Found %@ at (%.0f,%.0f,%.0f,%.0f) - "
1010
+ @"view.window=%@ targetWindow=%@",
1011
+ isTextInput
1012
+ ? @"TextInput"
1013
+ : (isCamera ? @"Camera"
1014
+ : (isWebView ? @"WebView"
1015
+ : (isVideo ? @"Video" : @"MaskedView"))),
1016
+ sanitizedFrame.origin.x, sanitizedFrame.origin.y,
1017
+ sanitizedFrame.size.width, sanitizedFrame.size.height,
1018
+ NSStringFromClass([view.window class]),
1019
+ NSStringFromClass([targetWindow class]));
1013
1020
  #endif
1014
1021
  }
1015
1022
  }
@@ -1212,7 +1219,7 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
1212
1219
 
1213
1220
  @try {
1214
1221
  if ([view respondsToSelector:@selector(isLoading)]) {
1215
- BOOL (*loadingMsg)(id, SEL) = (BOOL (*)(id, SEL))objc_msgSend;
1222
+ BOOL (*loadingMsg)(id, SEL) = (BOOL(*)(id, SEL))objc_msgSend;
1216
1223
  return loadingMsg(view, @selector(isLoading));
1217
1224
  }
1218
1225
  id loadingValue = [view valueForKey:@"loading"];
@@ -1314,8 +1321,9 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
1314
1321
 
1315
1322
  BOOL tracking = scrollView.isTracking || scrollView.isDragging ||
1316
1323
  scrollView.isDecelerating;
1317
- BOOL offsetMoved = (fabs(offset.x - state.contentOffset.x) > kRJScrollEpsilon ||
1318
- fabs(offset.y - state.contentOffset.y) > kRJScrollEpsilon);
1324
+ BOOL offsetMoved =
1325
+ (fabs(offset.x - state.contentOffset.x) > kRJScrollEpsilon ||
1326
+ fabs(offset.y - state.contentOffset.y) > kRJScrollEpsilon);
1319
1327
  BOOL zoomMoved = fabs(zoomScale - state.zoomScale) > kRJZoomEpsilon;
1320
1328
  if (tracking || offsetMoved || zoomMoved) {
1321
1329
  self.scanScrollActive = YES;
@@ -1331,8 +1339,8 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
1331
1339
  }
1332
1340
 
1333
1341
  if ([self isRefreshActiveForScrollView:scrollView
1334
- offset:offset
1335
- inset:inset]) {
1342
+ offset:offset
1343
+ inset:inset]) {
1336
1344
  self.scanRefreshActive = YES;
1337
1345
  }
1338
1346
 
@@ -1351,16 +1359,16 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
1351
1359
  }
1352
1360
  CGFloat topLimit = -inset.top - kRJScrollEpsilon;
1353
1361
  CGFloat bottomLimit = scrollView.contentSize.height -
1354
- scrollView.bounds.size.height +
1355
- inset.bottom + kRJScrollEpsilon;
1362
+ scrollView.bounds.size.height + inset.bottom +
1363
+ kRJScrollEpsilon;
1356
1364
  if (offset.y < topLimit || offset.y > bottomLimit) {
1357
1365
  return YES;
1358
1366
  }
1359
1367
 
1360
1368
  CGFloat leftLimit = -inset.left - kRJScrollEpsilon;
1361
1369
  CGFloat rightLimit = scrollView.contentSize.width -
1362
- scrollView.bounds.size.width +
1363
- inset.right + kRJScrollEpsilon;
1370
+ scrollView.bounds.size.width + inset.right +
1371
+ kRJScrollEpsilon;
1364
1372
  return (offset.x < leftLimit || offset.x > rightLimit);
1365
1373
  }
1366
1374
 
@@ -1376,8 +1384,8 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
1376
1384
  return YES;
1377
1385
  }
1378
1386
 
1379
- CGFloat triggerOffset = -scrollView.adjustedContentInset.top -
1380
- kRJScrollEpsilon;
1387
+ CGFloat triggerOffset =
1388
+ -scrollView.adjustedContentInset.top - kRJScrollEpsilon;
1381
1389
  if (offset.y < triggerOffset) {
1382
1390
  return YES;
1383
1391
  }
@@ -1448,14 +1456,23 @@ static inline uint64_t fnv1a_u64(uint64_t h, const void *data, size_t len) {
1448
1456
  NSNumber *heading = [view valueForKeyPath:@"camera.heading"];
1449
1457
  NSNumber *pitch = [view valueForKeyPath:@"camera.pitch"];
1450
1458
 
1459
+ // Mapbox uses zoomLevel instead of span
1460
+ NSNumber *zoomLevel = nil;
1461
+ @try {
1462
+ zoomLevel = [view valueForKey:@"zoomLevel"];
1463
+ } @catch (NSException *e) {
1464
+ }
1465
+
1451
1466
  double altitudeValue = altitude ? altitude.doubleValue : 0;
1452
1467
  double headingValue = heading ? heading.doubleValue : 0;
1453
1468
  double pitchValue = pitch ? pitch.doubleValue : 0;
1469
+ double zoomValue = zoomLevel ? zoomLevel.doubleValue : 0;
1454
1470
 
1455
- return [NSString stringWithFormat:@"%.5f:%.5f:%.5f:%.5f:%.1f:%.1f:%.1f",
1456
- center.latitude, center.longitude,
1457
- span.latitudeDelta, span.longitudeDelta,
1458
- altitudeValue, headingValue, pitchValue];
1471
+ return [NSString
1472
+ stringWithFormat:@"%.5f:%.5f:%.5f:%.5f:%.1f:%.1f:%.1f:%.2f",
1473
+ center.latitude, center.longitude, span.latitudeDelta,
1474
+ span.longitudeDelta, altitudeValue, headingValue,
1475
+ pitchValue, zoomValue];
1459
1476
  } @catch (NSException *exception) {
1460
1477
  return @"";
1461
1478
  }
@@ -121,7 +121,6 @@
121
121
  name:UIApplicationDidBecomeActiveNotification
122
122
  object:nil];
123
123
 
124
- // Text change notifications
125
124
  [center addObserver:self
126
125
  selector:@selector(textDidChange:)
127
126
  name:UITextFieldTextDidChangeNotification
@@ -220,8 +219,6 @@
220
219
  RJLogInfo(@"[RJ-LIFECYCLE] appDidEnterBackground (isRecording=%@)",
221
220
  self.isRecording ? @"YES" : @"NO");
222
221
 
223
- // Always track background entry time, even if not recording
224
- // This allows us to detect timeout when session was ended while in background
225
222
  self.inBackground = YES;
226
223
  self.backgroundEntryTime = [[NSDate date] timeIntervalSince1970];
227
224
 
@@ -257,13 +254,10 @@
257
254
  RJLogInfo(@"[RJ-LIFECYCLE] appDidBecomeActive - was NOT in background");
258
255
  }
259
256
 
260
- // Reset background tracking state
261
257
  self.inBackground = NO;
262
258
  self.backgroundEntryTime = 0;
263
259
 
264
- // Handle the case where we weren't recording (session already ended)
265
260
  if (!self.isRecording) {
266
- // If we were in background long enough, signal that a new session should start
267
261
  if (wasInBackground && backgroundDurationSec >= self.backgroundTimeoutThreshold) {
268
262
  RJLogInfo(@"[RJ-LIFECYCLE] Was not recording, background >= %.0fs - signaling for new session start",
269
263
  self.backgroundTimeoutThreshold);
@@ -274,14 +268,10 @@
274
268
  return;
275
269
  }
276
270
 
277
- // We ARE recording - handle background return
278
271
  if (wasInBackground) {
279
272
  NSTimeInterval bgDurationMs = backgroundDurationSec * 1000;
280
273
 
281
274
  if (backgroundDurationSec >= self.backgroundTimeoutThreshold) {
282
- // TIMEOUT CASE: End old session, start new one
283
- // Add this background duration to accumulated time BEFORE signaling timeout
284
- // so the old session gets the correct total background time
285
275
  self.accumulatedBackgroundTimeMs += bgDurationMs;
286
276
  RJLogInfo(@"[RJ-LIFECYCLE] TIMEOUT: Added %.0fms, total background=%.0fms - signaling session restart",
287
277
  bgDurationMs, self.accumulatedBackgroundTimeMs);
@@ -289,17 +279,13 @@
289
279
  if ([self.delegate respondsToSelector:@selector(lifecycleManagerSessionDidTimeout:)]) {
290
280
  [self.delegate lifecycleManagerSessionDidTimeout:backgroundDurationSec];
291
281
  }
292
- // Note: The delegate's handleSessionTimeout will read totalBackgroundTimeMs
293
- // and then call resetBackgroundTime for the new session
294
282
  } else {
295
- // SHORT BACKGROUND: Just accumulate and resume
296
283
  self.accumulatedBackgroundTimeMs += bgDurationMs;
297
284
  RJLogInfo(@"[RJ-LIFECYCLE] Short background: Added %.0fms, total=%.0fms - resuming session",
298
285
  bgDurationMs, self.accumulatedBackgroundTimeMs);
299
286
  }
300
287
  }
301
288
 
302
- // Call didBecomeActive for normal resume handling (video capture, etc.)
303
289
  if ([self.delegate respondsToSelector:@selector(lifecycleManagerDidBecomeActive)]) {
304
290
  [self.delegate lifecycleManagerDidBecomeActive];
305
291
  }
@@ -249,22 +249,33 @@ RCT_EXPORT_MODULE()
249
249
  didDecideSample = YES;
250
250
  }
251
251
 
252
- BOOL shouldRecord = self.recordingEnabledByConfig && self.sessionSampled;
253
- self.remoteRecordingEnabled = shouldRecord;
252
+ // Decouple video recording from session active state.
253
+ // We want to record video ONLY if:
254
+ // 1. Config says recording is enabled (remote toggles)
255
+ // 2. Sample rate allows it
256
+ //
257
+ // NOTE: Even if shouldRecordVideo is NO, the session remains active
258
+ // (isRecording=YES) and events are still observed and uploaded ("Data-Only
259
+ // Mode").
260
+ BOOL shouldRecordVideo = self.recordingEnabledByConfig && self.sessionSampled;
261
+ self.remoteRecordingEnabled = shouldRecordVideo;
254
262
 
255
263
  if (self.captureEngine) {
256
- self.captureEngine.uploadsEnabled = shouldRecord;
257
- if (!shouldRecord && self.captureEngine.isRecording) {
264
+ self.captureEngine.uploadsEnabled = shouldRecordVideo;
265
+ if (!shouldRecordVideo && self.captureEngine.isRecording) {
266
+ // Stop video capture pipeline to save resources, but keep session alive
258
267
  [self.captureEngine stopSession];
259
268
  }
260
269
  }
261
270
 
262
271
  if (didDecideSample && self.recordingEnabledByConfig &&
263
272
  !self.sessionSampled) {
264
- RJLogWarning(@"Session skipped by sample rate (%ld%%)", (long)clampedRate);
273
+ RJLogInfo(@"Session sampled out for video (%ld%%) - entering Data-Only "
274
+ @"Mode (Events enabled, Video disabled)",
275
+ (long)clampedRate);
265
276
  }
266
277
 
267
- return shouldRecord;
278
+ return shouldRecordVideo;
268
279
  }
269
280
 
270
281
  - (void)ensureFullyInitialized {
@@ -676,10 +687,7 @@ RCT_EXPORT_METHOD(stopSession : (RCTPromiseResolveBlock)
676
687
 
677
688
  NSString *sessionId = self.currentSessionId ?: @"";
678
689
 
679
- // CRITICAL: Compute background time at the moment we end the session.
680
- // When a session ends while the app is in background (e.g. max duration),
681
- // we must include the ongoing background duration, otherwise the backend
682
- // will think the entire wall-clock session duration is playable.
690
+
683
691
  NSTimeInterval totalBgTimeMs = 0;
684
692
  if (self.lifecycleManager) {
685
693
  totalBgTimeMs = self.lifecycleManager.totalBackgroundTimeMs;
@@ -1037,7 +1045,7 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
1037
1045
  if ([gestureType hasPrefix:@"scroll"]) {
1038
1046
  static NSTimeInterval lastScrollLogTime = 0;
1039
1047
  NSTimeInterval now = CACurrentMediaTime();
1040
- if (now - lastScrollLogTime < 0.5) {
1048
+ if (now - lastScrollLogTime < 0.5) {
1041
1049
  return;
1042
1050
  }
1043
1051
  lastScrollLogTime = now;
@@ -1125,7 +1133,7 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
1125
1133
  registerDeviceWithProjectKey:publicKey
1126
1134
  bundleId:bundleId
1127
1135
  platform:@"ios"
1128
- sdkVersion:@"1.0.0"
1136
+ sdkVersion:RJSDKVersion
1129
1137
  apiUrl:apiUrl
1130
1138
  completion:^(BOOL success, NSString *credId,
1131
1139
  NSError *error) {
@@ -1329,12 +1337,10 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
1329
1337
  return;
1330
1338
  }
1331
1339
 
1332
- // Handle security errors (403 = forbidden/mismatch, 404 = not found)
1333
1340
  if ((error.code == 403 || error.code == 404) &&
1334
1341
  [error.domain isEqualToString:@"RJDeviceAuth"]) {
1335
1342
 
1336
1343
  if (!isRetry) {
1337
- // First failure - try re-registration
1338
1344
  RJLogDebug(@"Device auth invalid (%ld), attempting re-registration...",
1339
1345
  (long)error.code);
1340
1346
  NSString *bundleId =
@@ -1345,7 +1351,7 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
1345
1351
  registerDeviceWithProjectKey:publicKey
1346
1352
  bundleId:bundleId
1347
1353
  platform:@"ios"
1348
- sdkVersion:@"1.0.0"
1354
+ sdkVersion:RJSDKVersion
1349
1355
  apiUrl:apiUrl
1350
1356
  completion:^(BOOL retrySuccess, NSString *credId,
1351
1357
  NSError *retryError) {
@@ -2112,7 +2118,7 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2112
2118
  }
2113
2119
 
2114
2120
  if (self.captureEngine) {
2115
-
2121
+
2116
2122
  RJLogInfo(@"[RJ-TERMINATE] Stopping capture engine synchronously");
2117
2123
  [self.captureEngine stopSessionSync];
2118
2124
  RJLogInfo(@"[RJ-TERMINATE] Capture engine stopped");
@@ -2146,9 +2152,9 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2146
2152
 
2147
2153
  if (self.uploadManager && events.count > 0) {
2148
2154
  @try {
2149
- [self.uploadManager synchronousUploadWithEvents:events ?: @[]];
2155
+ [self.uploadManager persistTerminationEvents:events ?: @[]];
2150
2156
  } @catch (NSException *e) {
2151
- RJLogWarning(@"Terminate upload failed: %@", e);
2157
+ RJLogWarning(@"Terminate persistence failed: %@", e);
2152
2158
  }
2153
2159
  }
2154
2160
  } @catch (NSException *exception) {
@@ -2212,13 +2218,6 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2212
2218
  [self handleSessionTimeout:backgroundDuration currentTime:currentTime];
2213
2219
  }
2214
2220
 
2215
-
2216
- /// 1. Capture final background time from lifecycle manager
2217
- /// 2. Stop timers and capture engine for old session
2218
- /// 3. Synchronously end the old session with correct background time
2219
- /// 4. Create new session ID and reset all state
2220
- /// 5. Start capture for new session
2221
- /// 6. Trigger immediate upload to register new session
2222
2221
  - (void)handleSessionTimeout:(NSTimeInterval)backgroundDuration
2223
2222
  currentTime:(NSTimeInterval)currentTime {
2224
2223
  RJLogInfo(
@@ -2232,8 +2231,6 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2232
2231
  RJLogInfo(@"[RJ-SESSION-TIMEOUT] === ENDING OLD SESSION: %@ ===",
2233
2232
  oldSessionId);
2234
2233
 
2235
- // ========== STEP 1: Capture background time BEFORE any state changes
2236
- // ==========
2237
2234
  NSTimeInterval totalBackgroundMs = 0;
2238
2235
  if (self.lifecycleManager) {
2239
2236
  totalBackgroundMs = self.lifecycleManager.totalBackgroundTimeMs;
@@ -2242,7 +2239,6 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2242
2239
  totalBackgroundMs);
2243
2240
  }
2244
2241
 
2245
- // ========== STEP 2: Stop all capture/timers for old session ==========
2246
2242
  [self stopBatchUploadTimer];
2247
2243
  [self stopDurationLimitTimer];
2248
2244
 
@@ -2255,14 +2251,10 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2255
2251
  }
2256
2252
  }
2257
2253
 
2258
- // ========== STEP 3: End old session SYNCHRONOUSLY with correct background
2259
- // time ==========
2260
2254
  if (wasRecording && self.uploadManager && oldSessionId.length > 0 &&
2261
2255
  ![oldSessionId isEqualToString:@"none"]) {
2262
- // Set background time on upload manager
2263
2256
  self.uploadManager.totalBackgroundTimeMs = totalBackgroundMs;
2264
2257
 
2265
- // Get current events for final upload
2266
2258
  __block NSArray<NSDictionary *> *finalEvents = nil;
2267
2259
  [self performStateSync:^{
2268
2260
  finalEvents = [self.sessionEvents copy];
@@ -2272,7 +2264,6 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2272
2264
  @"bgTime=%.0fms",
2273
2265
  (unsigned long)finalEvents.count, totalBackgroundMs);
2274
2266
 
2275
- // Synchronous upload and session end
2276
2267
  if (finalEvents.count > 0) {
2277
2268
  [self.uploadManager synchronousUploadWithEvents:finalEvents];
2278
2269
  } else {
@@ -2282,7 +2273,6 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2282
2273
  RJLogInfo(@"[RJ-SESSION-TIMEOUT] Old session %@ ended", oldSessionId);
2283
2274
  }
2284
2275
 
2285
- // ========== STEP 4: Reset ALL state for new session ==========
2286
2276
  RJLogInfo(@"[RJ-SESSION-TIMEOUT] === STARTING NEW SESSION ===");
2287
2277
 
2288
2278
  __block NSString *newSessionId = nil;
@@ -2336,7 +2326,6 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2336
2326
  self.eventBuffer = [[RJEventBuffer alloc] initWithSessionId:newSessionId
2337
2327
  pendingRootPath:pendingPath];
2338
2328
 
2339
- // ========== STEP 5: Start capture for new session ==========
2340
2329
  [self resetSamplingDecision];
2341
2330
  self.remoteRecordingEnabled = self.recordingEnabledByConfig;
2342
2331
  if (self.captureEngine) {
@@ -2367,8 +2356,6 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
2367
2356
  [self startBatchUploadTimer];
2368
2357
  [self startDurationLimitTimer];
2369
2358
 
2370
- // ========== STEP 6: Log session_start and trigger immediate upload
2371
- // ==========
2372
2359
  NSMutableDictionary *sessionStartEvent = [NSMutableDictionary dictionary];
2373
2360
  sessionStartEvent[@"type"] = RJEventTypeSessionStart;
2374
2361
  sessionStartEvent[@"timestamp"] = @([RJWindowUtils currentTimestampMillis]);
@@ -857,10 +857,8 @@ static const NSInteger RJ_AUTH_MAX_CONSECUTIVE_FAILURES = 10;
857
857
  return;
858
858
  }
859
859
 
860
- // Check cooldown after consecutive failures to prevent flooding
861
860
  NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
862
861
  if (self.consecutiveFailures > 0 && self.lastFailedRegistrationTime > 0) {
863
- // Exponential backoff: 5s, 10s, 20s, 40s... up to 5 minutes
864
862
  NSTimeInterval cooldown = MIN(
865
863
  RJ_AUTH_COOLDOWN_BASE_SECONDS * pow(2, self.consecutiveFailures - 1),
866
864
  RJ_AUTH_COOLDOWN_MAX_SECONDS
@@ -180,6 +180,14 @@ NS_ASSUME_NONNULL_BEGIN
180
180
  */
181
181
  - (BOOL)synchronousUploadWithEvents:(NSArray<NSDictionary *> *)events;
182
182
 
183
+ /**
184
+ * Persists events to disk for termination, skipping network upload.
185
+ * Use this in appWillTerminate to avoid watchdog kills.
186
+ *
187
+ * @param events Array of event dictionaries.
188
+ */
189
+ - (void)persistTerminationEvents:(NSArray<NSDictionary *> *)events;
190
+
183
191
  /**
184
192
  * Uploads a crash report to the dashboard.
185
193
  *
@@ -67,6 +67,7 @@ static NSString *RJRedactedURLForLogFromString(NSString *urlString) {
67
67
 
68
68
  @interface RJUploadManager ()
69
69
 
70
+ @property(nonatomic, assign) BOOL keyboardVisible;
70
71
  @property(nonatomic, strong) dispatch_queue_t uploadQueue;
71
72
 
72
73
  @property(nonatomic, strong, nullable) NSTimer *batchUploadTimer;
@@ -1820,6 +1821,50 @@ static NSString *RJRedactedURLForLogFromString(NSString *urlString) {
1820
1821
  return YES;
1821
1822
  }
1822
1823
 
1824
+ - (void)persistTerminationEvents:(NSArray<NSDictionary *> *)events {
1825
+ if (events.count == 0 && self.sessionId.length == 0)
1826
+ return;
1827
+
1828
+ self.eventBatchNumber += 1;
1829
+ NSInteger currentBatch = self.eventBatchNumber;
1830
+
1831
+ RJLogInfo(@"[RJ-UPLOAD] persistTerminationEvents starting: sessionId=%@, "
1832
+ @"batch=%ld, "
1833
+ @"eventCount=%lu",
1834
+ self.sessionId ?: @"<nil>", (long)currentBatch,
1835
+ (unsigned long)events.count);
1836
+
1837
+ NSDictionary *payload = [self buildEventPayloadWithEvents:events
1838
+ batchNumber:currentBatch
1839
+ isFinal:YES];
1840
+ if (!payload)
1841
+ return;
1842
+
1843
+ NSError *jsonError = nil;
1844
+ NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload
1845
+ options:0
1846
+ error:&jsonError];
1847
+ if (jsonError || !jsonData)
1848
+ return;
1849
+
1850
+ NSError *gzipError = nil;
1851
+ NSData *compressed = RJGzipData(jsonData, &gzipError);
1852
+ if (gzipError || !compressed)
1853
+ return;
1854
+
1855
+ [self persistPendingUploadWithContentType:@"events"
1856
+ batchNumber:currentBatch
1857
+ keyframe:NO
1858
+ gzipped:compressed
1859
+ eventCount:events.count
1860
+ frameCount:0];
1861
+
1862
+ [self markSessionActiveForRecovery];
1863
+
1864
+ RJLogInfo(@"[RJ-CHECKPOINT] Persisted termination events to disk, skipping "
1865
+ @"sync upload");
1866
+ }
1867
+
1823
1868
  - (BOOL)uploadEventsBatchSync:(NSArray<NSDictionary *> *)events
1824
1869
  isFinal:(BOOL)isFinal {
1825
1870
  self.eventBatchNumber += 1;