@rejourneyco/react-native 1.0.0

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 (152) hide show
  1. package/android/build.gradle.kts +135 -0
  2. package/android/consumer-rules.pro +10 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/main/AndroidManifest.xml +15 -0
  5. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
  6. package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
  7. package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
  8. package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
  9. package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
  10. package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
  11. package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
  12. package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
  13. package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
  14. package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
  15. package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
  16. package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
  17. package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
  18. package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
  19. package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
  20. package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
  21. package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
  22. package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
  23. package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
  24. package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
  25. package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
  26. package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
  27. package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
  28. package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
  29. package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
  30. package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
  31. package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
  32. package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
  33. package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
  34. package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
  35. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
  36. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  37. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
  38. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  39. package/ios/Capture/RJANRHandler.h +42 -0
  40. package/ios/Capture/RJANRHandler.m +328 -0
  41. package/ios/Capture/RJCaptureEngine.h +275 -0
  42. package/ios/Capture/RJCaptureEngine.m +2062 -0
  43. package/ios/Capture/RJCaptureHeuristics.h +80 -0
  44. package/ios/Capture/RJCaptureHeuristics.m +903 -0
  45. package/ios/Capture/RJCrashHandler.h +46 -0
  46. package/ios/Capture/RJCrashHandler.m +313 -0
  47. package/ios/Capture/RJMotionEvent.h +183 -0
  48. package/ios/Capture/RJMotionEvent.m +183 -0
  49. package/ios/Capture/RJPerformanceManager.h +100 -0
  50. package/ios/Capture/RJPerformanceManager.m +373 -0
  51. package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
  52. package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
  53. package/ios/Capture/RJSegmentUploader.h +146 -0
  54. package/ios/Capture/RJSegmentUploader.m +778 -0
  55. package/ios/Capture/RJVideoEncoder.h +247 -0
  56. package/ios/Capture/RJVideoEncoder.m +1036 -0
  57. package/ios/Capture/RJViewControllerTracker.h +73 -0
  58. package/ios/Capture/RJViewControllerTracker.m +508 -0
  59. package/ios/Capture/RJViewHierarchyScanner.h +215 -0
  60. package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
  61. package/ios/Capture/RJViewSerializer.h +119 -0
  62. package/ios/Capture/RJViewSerializer.m +498 -0
  63. package/ios/Core/RJConstants.h +124 -0
  64. package/ios/Core/RJConstants.m +88 -0
  65. package/ios/Core/RJLifecycleManager.h +85 -0
  66. package/ios/Core/RJLifecycleManager.m +308 -0
  67. package/ios/Core/RJLogger.h +61 -0
  68. package/ios/Core/RJLogger.m +211 -0
  69. package/ios/Core/RJTypes.h +176 -0
  70. package/ios/Core/RJTypes.m +66 -0
  71. package/ios/Core/Rejourney.h +64 -0
  72. package/ios/Core/Rejourney.mm +2495 -0
  73. package/ios/Network/RJDeviceAuthManager.h +94 -0
  74. package/ios/Network/RJDeviceAuthManager.m +967 -0
  75. package/ios/Network/RJNetworkMonitor.h +68 -0
  76. package/ios/Network/RJNetworkMonitor.m +267 -0
  77. package/ios/Network/RJRetryManager.h +73 -0
  78. package/ios/Network/RJRetryManager.m +325 -0
  79. package/ios/Network/RJUploadManager.h +267 -0
  80. package/ios/Network/RJUploadManager.m +2296 -0
  81. package/ios/Privacy/RJPrivacyMask.h +163 -0
  82. package/ios/Privacy/RJPrivacyMask.m +922 -0
  83. package/ios/Rejourney.h +63 -0
  84. package/ios/Touch/RJGestureClassifier.h +130 -0
  85. package/ios/Touch/RJGestureClassifier.m +333 -0
  86. package/ios/Touch/RJTouchInterceptor.h +169 -0
  87. package/ios/Touch/RJTouchInterceptor.m +772 -0
  88. package/ios/Utils/RJEventBuffer.h +112 -0
  89. package/ios/Utils/RJEventBuffer.m +358 -0
  90. package/ios/Utils/RJGzipUtils.h +33 -0
  91. package/ios/Utils/RJGzipUtils.m +89 -0
  92. package/ios/Utils/RJKeychainManager.h +48 -0
  93. package/ios/Utils/RJKeychainManager.m +111 -0
  94. package/ios/Utils/RJPerfTiming.h +209 -0
  95. package/ios/Utils/RJPerfTiming.m +264 -0
  96. package/ios/Utils/RJTelemetry.h +92 -0
  97. package/ios/Utils/RJTelemetry.m +320 -0
  98. package/ios/Utils/RJWindowUtils.h +66 -0
  99. package/ios/Utils/RJWindowUtils.m +133 -0
  100. package/lib/commonjs/NativeRejourney.js +40 -0
  101. package/lib/commonjs/components/Mask.js +79 -0
  102. package/lib/commonjs/index.js +1381 -0
  103. package/lib/commonjs/sdk/autoTracking.js +1259 -0
  104. package/lib/commonjs/sdk/constants.js +151 -0
  105. package/lib/commonjs/sdk/errorTracking.js +199 -0
  106. package/lib/commonjs/sdk/index.js +50 -0
  107. package/lib/commonjs/sdk/metricsTracking.js +204 -0
  108. package/lib/commonjs/sdk/navigation.js +151 -0
  109. package/lib/commonjs/sdk/networkInterceptor.js +412 -0
  110. package/lib/commonjs/sdk/utils.js +363 -0
  111. package/lib/commonjs/types/expo-router.d.js +2 -0
  112. package/lib/commonjs/types/index.js +2 -0
  113. package/lib/module/NativeRejourney.js +38 -0
  114. package/lib/module/components/Mask.js +72 -0
  115. package/lib/module/index.js +1284 -0
  116. package/lib/module/sdk/autoTracking.js +1233 -0
  117. package/lib/module/sdk/constants.js +145 -0
  118. package/lib/module/sdk/errorTracking.js +189 -0
  119. package/lib/module/sdk/index.js +12 -0
  120. package/lib/module/sdk/metricsTracking.js +187 -0
  121. package/lib/module/sdk/navigation.js +143 -0
  122. package/lib/module/sdk/networkInterceptor.js +401 -0
  123. package/lib/module/sdk/utils.js +342 -0
  124. package/lib/module/types/expo-router.d.js +2 -0
  125. package/lib/module/types/index.js +2 -0
  126. package/lib/typescript/NativeRejourney.d.ts +147 -0
  127. package/lib/typescript/components/Mask.d.ts +39 -0
  128. package/lib/typescript/index.d.ts +117 -0
  129. package/lib/typescript/sdk/autoTracking.d.ts +204 -0
  130. package/lib/typescript/sdk/constants.d.ts +120 -0
  131. package/lib/typescript/sdk/errorTracking.d.ts +32 -0
  132. package/lib/typescript/sdk/index.d.ts +9 -0
  133. package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
  134. package/lib/typescript/sdk/navigation.d.ts +33 -0
  135. package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
  136. package/lib/typescript/sdk/utils.d.ts +148 -0
  137. package/lib/typescript/types/index.d.ts +624 -0
  138. package/package.json +102 -0
  139. package/rejourney.podspec +21 -0
  140. package/src/NativeRejourney.ts +165 -0
  141. package/src/components/Mask.tsx +80 -0
  142. package/src/index.ts +1459 -0
  143. package/src/sdk/autoTracking.ts +1373 -0
  144. package/src/sdk/constants.ts +134 -0
  145. package/src/sdk/errorTracking.ts +231 -0
  146. package/src/sdk/index.ts +11 -0
  147. package/src/sdk/metricsTracking.ts +232 -0
  148. package/src/sdk/navigation.ts +157 -0
  149. package/src/sdk/networkInterceptor.ts +440 -0
  150. package/src/sdk/utils.ts +369 -0
  151. package/src/types/expo-router.d.ts +7 -0
  152. package/src/types/index.ts +739 -0
@@ -0,0 +1,2495 @@
1
+ //
2
+ // Rejourney.mm
3
+ // Rejourney
4
+ //
5
+ // React Native module for efficient session recording.
6
+ //
7
+ // Licensed under the Apache License, Version 2.0 (the "License");
8
+ // you may not use this file except in compliance with the License.
9
+ // You may obtain a copy of the License at
10
+ //
11
+ // http://www.apache.org/licenses/LICENSE-2.0
12
+ //
13
+ // Unless required by applicable law or agreed to in writing, software
14
+ // distributed under the License is distributed on an "AS IS" BASIS,
15
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ // See the License for the specific language governing permissions and
17
+ // limitations under the License.
18
+ //
19
+ // Copyright (c) 2026 Rejourney
20
+ //
21
+
22
+ #import "Rejourney.h"
23
+ #import "../Capture/RJANRHandler.h"
24
+ #import "../Capture/RJCaptureEngine.h"
25
+ #import "../Capture/RJCrashHandler.h"
26
+ #import "../Capture/RJMotionEvent.h"
27
+ #import "../Capture/RJSegmentUploader.h"
28
+ #import "../Capture/RJVideoEncoder.h"
29
+ #import "../Capture/RJViewControllerTracker.h"
30
+ #import "../Network/RJDeviceAuthManager.h"
31
+ #import "../Network/RJNetworkMonitor.h"
32
+ #import "../Network/RJUploadManager.h"
33
+ #import "../Privacy/RJPrivacyMask.h"
34
+ #import "../Touch/RJTouchInterceptor.h"
35
+ #import "../Utils/RJEventBuffer.h"
36
+ #import "../Utils/RJTelemetry.h"
37
+ #import "../Utils/RJWindowUtils.h"
38
+ #import "RJConstants.h"
39
+ #import "RJLifecycleManager.h"
40
+ #import "RJLogger.h"
41
+ #import "RJTypes.h"
42
+
43
+ #import <CommonCrypto/CommonDigest.h>
44
+ #import <React/RCTLog.h>
45
+ #import <React/RCTUIManager.h>
46
+ #import <UIKit/UIKit.h>
47
+ #import <mach/mach_time.h>
48
+ #import <sys/sysctl.h>
49
+
50
+ static uint64_t _rj_constructorMachTime = 0;
51
+ static NSTimeInterval _rj_constructorWallTimeMs = 0;
52
+ static void *kRJStateQueueKey = &kRJStateQueueKey;
53
+
54
+ __attribute__((constructor)) static void rj_captureProcessStartTime(void) {
55
+
56
+ _rj_constructorMachTime = mach_absolute_time();
57
+ _rj_constructorWallTimeMs = [[NSDate date] timeIntervalSince1970] * 1000.0;
58
+ }
59
+
60
+ #pragma mark - Private Interface
61
+
62
+ @interface Rejourney () <RJTouchInterceptorDelegate,
63
+ RJViewControllerTrackerDelegate,
64
+ RJLifecycleManagerDelegate, RJANRHandlerDelegate>
65
+
66
+ @property(nonatomic, strong) RJCaptureEngine *captureEngine;
67
+ @property(nonatomic, strong) RJUploadManager *uploadManager;
68
+ @property(nonatomic, strong) RJLifecycleManager *lifecycleManager;
69
+
70
+ @property(nonatomic, strong) dispatch_queue_t stateQueue;
71
+
72
+ @property(nonatomic, copy, nullable) NSString *currentSessionId;
73
+ @property(nonatomic, copy, nullable) NSString *userId;
74
+ @property(atomic, assign) BOOL isRecording;
75
+ @property(atomic, assign) BOOL remoteRejourneyEnabled;
76
+ @property(atomic, assign) BOOL remoteRecordingEnabled;
77
+ @property(atomic, assign) BOOL recordingEnabledByConfig;
78
+ @property(atomic, assign) NSInteger sampleRate;
79
+ @property(atomic, assign) BOOL sessionSampled;
80
+ @property(atomic, assign) BOOL hasSampleDecision;
81
+ @property(atomic, assign) BOOL hasProjectConfig;
82
+ @property(atomic, assign) BOOL remoteBillingBlocked;
83
+ @property(nonatomic, assign) NSTimeInterval sessionStartTime;
84
+
85
+ @property(nonatomic, strong) NSMutableArray<NSDictionary *> *sessionEvents;
86
+
87
+ @property(nonatomic, strong, nullable) RJEventBuffer *eventBuffer;
88
+
89
+ @property(atomic, assign) UIBackgroundTaskIdentifier backgroundTaskId;
90
+
91
+ @property(nonatomic, strong, nullable) NSTimer *batchUploadTimer;
92
+ @property(nonatomic, assign) NSTimeInterval lastUploadTime;
93
+
94
+ @property(nonatomic, assign) NSTimeInterval lastImmediateUploadKickMs;
95
+
96
+ @property(nonatomic, assign) NSInteger maxRecordingMinutes;
97
+ @property(nonatomic, strong, nullable) NSTimer *durationLimitTimer;
98
+
99
+ @property(atomic, assign) BOOL isShuttingDown;
100
+
101
+ @property(atomic, assign) BOOL fullyInitialized;
102
+
103
+ @property(nonatomic, assign) NSTimeInterval totalBackgroundTimeMs;
104
+ @property(nonatomic, assign) BOOL didOpenExternalURL;
105
+ @property(nonatomic, copy, nullable) NSString *lastOpenedURLScheme;
106
+
107
+ @property(nonatomic, assign) NSTimeInterval lastKeyboardTypingEventTimeMs;
108
+
109
+ // Auth resilience - retry mechanism
110
+ @property(nonatomic, assign) NSInteger authRetryCount;
111
+ @property(nonatomic, assign) NSTimeInterval nextAuthRetryTime;
112
+ @property(nonatomic, strong, nullable) NSTimer *authRetryTimer;
113
+ @property(atomic, assign)
114
+ BOOL authPermanentlyFailed; // Only for 403 (security issues)
115
+
116
+ - (void)performStateSync:(dispatch_block_t)block;
117
+ - (void)resetSamplingDecision;
118
+ - (BOOL)updateRecordingEligibilityWithSampleRate:(NSInteger)sampleRate;
119
+
120
+ @end
121
+
122
+ // Constants for auth retry
123
+ static const NSInteger RJ_MAX_AUTH_RETRIES = 5;
124
+ static const NSTimeInterval RJ_AUTH_RETRY_BASE_DELAY = 2.0; // 2 seconds base
125
+ static const NSTimeInterval RJ_AUTH_RETRY_MAX_DELAY = 60.0; // 1 minute max
126
+
127
+ #pragma mark - Implementation
128
+
129
+ @implementation Rejourney
130
+
131
+ RCT_EXPORT_MODULE()
132
+
133
+ #pragma mark - Module Setup
134
+
135
+ + (BOOL)requiresMainQueueSetup {
136
+ return YES;
137
+ }
138
+
139
+ #pragma mark - Initialization
140
+
141
+ - (instancetype)init {
142
+ self = [super init];
143
+ if (self) {
144
+ @try {
145
+
146
+ [self setupMinimalComponents];
147
+ [self registerNotifications];
148
+
149
+ } @catch (NSException *exception) {
150
+ [RJLogger logInitFailure:[exception reason]];
151
+ }
152
+ }
153
+ return self;
154
+ }
155
+
156
+ - (void)logReactNativeArchitecture {
157
+ BOOL isNewArchitecture = NO;
158
+ NSString *architectureType = @"Unknown";
159
+
160
+ #ifdef RCT_NEW_ARCH_ENABLED
161
+ isNewArchitecture = YES;
162
+ architectureType = @"New Architecture (TurboModules)";
163
+ #else
164
+
165
+ Class turboModuleManagerClass = NSClassFromString(@"RCTTurboModuleManager");
166
+ Class fabricSurfaceClass = NSClassFromString(@"RCTFabricSurface");
167
+ Class rctHostClass = NSClassFromString(@"RCTHost");
168
+
169
+ if (rctHostClass != nil) {
170
+ isNewArchitecture = YES;
171
+ architectureType = @"New Architecture (Bridgeless)";
172
+ } else if (turboModuleManagerClass != nil && fabricSurfaceClass != nil) {
173
+ isNewArchitecture = YES;
174
+ architectureType = @"New Architecture (TurboModules + Fabric)";
175
+ } else if (turboModuleManagerClass != nil) {
176
+ isNewArchitecture = YES;
177
+ architectureType = @"Hybrid (TurboModules with Bridge)";
178
+ } else {
179
+ architectureType = @"Old Architecture (Bridge)";
180
+ }
181
+ #endif
182
+ }
183
+
184
+ - (void)setupMinimalComponents {
185
+
186
+ _stateQueue =
187
+ dispatch_queue_create("com.rejourney.state", DISPATCH_QUEUE_SERIAL);
188
+ dispatch_queue_set_specific(_stateQueue, kRJStateQueueKey, kRJStateQueueKey,
189
+ NULL);
190
+
191
+ _isRecording = NO;
192
+ _remoteRejourneyEnabled = YES;
193
+ _remoteRecordingEnabled = YES;
194
+ _recordingEnabledByConfig = YES;
195
+ _sampleRate = 100;
196
+ _sessionSampled = YES;
197
+ _hasSampleDecision = NO;
198
+ _hasProjectConfig = NO;
199
+ _remoteBillingBlocked = NO;
200
+ _isShuttingDown = NO;
201
+ _fullyInitialized = NO;
202
+ _sessionEvents = [NSMutableArray new];
203
+ _backgroundTaskId = UIBackgroundTaskInvalid;
204
+ _maxRecordingMinutes = 10;
205
+ _lastKeyboardTypingEventTimeMs = 0;
206
+
207
+ _lifecycleManager = [[RJLifecycleManager alloc] init];
208
+ _lifecycleManager.delegate = self;
209
+ }
210
+
211
+ - (void)performStateSync:(dispatch_block_t)block {
212
+ if (!block || !self.stateQueue) {
213
+ return;
214
+ }
215
+
216
+ if (dispatch_get_specific(kRJStateQueueKey)) {
217
+ block();
218
+ } else {
219
+ dispatch_sync(self.stateQueue, block);
220
+ }
221
+ }
222
+
223
+ - (void)resetSamplingDecision {
224
+ self.sessionSampled = YES;
225
+ self.hasSampleDecision = NO;
226
+ }
227
+
228
+ - (BOOL)shouldSampleSessionForRate:(NSInteger)sampleRate {
229
+ NSInteger clampedRate = MAX(0, MIN(100, sampleRate));
230
+ if (clampedRate >= 100) {
231
+ return YES;
232
+ }
233
+ if (clampedRate <= 0) {
234
+ return NO;
235
+ }
236
+ uint32_t roll = arc4random_uniform(100);
237
+ return roll < (uint32_t)clampedRate;
238
+ }
239
+
240
+ - (BOOL)updateRecordingEligibilityWithSampleRate:(NSInteger)sampleRate {
241
+ NSInteger clampedRate = MAX(0, MIN(100, sampleRate));
242
+ self.sampleRate = clampedRate;
243
+
244
+ BOOL didDecideSample = NO;
245
+ if (!self.hasSampleDecision) {
246
+ self.sessionSampled = [self shouldSampleSessionForRate:clampedRate];
247
+ self.hasSampleDecision = YES;
248
+ didDecideSample = YES;
249
+ }
250
+
251
+ BOOL shouldRecord = self.recordingEnabledByConfig && self.sessionSampled;
252
+ self.remoteRecordingEnabled = shouldRecord;
253
+
254
+ if (self.captureEngine) {
255
+ self.captureEngine.uploadsEnabled = shouldRecord;
256
+ if (!shouldRecord && self.captureEngine.isRecording) {
257
+ [self.captureEngine stopSession];
258
+ }
259
+ }
260
+
261
+ if (didDecideSample && self.recordingEnabledByConfig &&
262
+ !self.sessionSampled) {
263
+ RJLogWarning(@"Session skipped by sample rate (%ld%%)", (long)clampedRate);
264
+ }
265
+
266
+ return shouldRecord;
267
+ }
268
+
269
+ - (void)ensureFullyInitialized {
270
+ if (_fullyInitialized) {
271
+ return;
272
+ }
273
+ _fullyInitialized = YES;
274
+
275
+ [self logReactNativeArchitecture];
276
+
277
+ __weak __typeof__(self) weakSelf = self;
278
+ _captureEngine =
279
+ [[RJCaptureEngine alloc] initWithWindowProvider:^UIWindow *_Nullable {
280
+ __strong __typeof__(weakSelf) strongSelf = weakSelf;
281
+ if (!strongSelf || strongSelf.isShuttingDown)
282
+ return nil;
283
+ @try {
284
+ return [RJWindowUtils keyWindow];
285
+ } @catch (NSException *exception) {
286
+ RJLogWarning(@"Window provider exception: %@", exception);
287
+ return nil;
288
+ }
289
+ }];
290
+
291
+ _uploadManager =
292
+ [[RJUploadManager alloc] initWithApiUrl:@"https://api.rejourney.co"];
293
+
294
+ if (!_lifecycleManager) {
295
+ _lifecycleManager = [[RJLifecycleManager alloc] init];
296
+ _lifecycleManager.delegate = self;
297
+ }
298
+
299
+ [self setupTouchTracking];
300
+
301
+ [[RJCrashHandler sharedInstance] startMonitoring];
302
+
303
+ __weak RJCaptureEngine *weakCaptureEngine = _captureEngine;
304
+ [[RJCrashHandler sharedInstance] registerPreCrashCallback:^{
305
+ RJCaptureEngine *strongEngine = weakCaptureEngine;
306
+ if (strongEngine && strongEngine.videoEncoder) {
307
+ [strongEngine.videoEncoder emergencyFlushSync];
308
+ }
309
+ }];
310
+
311
+ [[RJANRHandler sharedInstance] setDelegate:self];
312
+ [[RJANRHandler sharedInstance] startMonitoring];
313
+ [[RJNetworkMonitor sharedInstance] startMonitoring];
314
+
315
+ [RJLogger logInitSuccess:RJSDKVersion];
316
+ }
317
+
318
+ - (void)setupTouchTracking {
319
+ dispatch_async(dispatch_get_main_queue(), ^{
320
+ @try {
321
+ [RJTouchInterceptor sharedInstance].delegate = self;
322
+ [[RJTouchInterceptor sharedInstance] enableGlobalTracking];
323
+ } @catch (NSException *exception) {
324
+ RJLogWarning(@"Touch tracking setup failed: %@", exception);
325
+ }
326
+ });
327
+ }
328
+
329
+ - (void)dealloc {
330
+
331
+ _isShuttingDown = YES;
332
+
333
+ if ([NSThread isMainThread]) {
334
+ [self stopBatchUploadTimer];
335
+ } else {
336
+ dispatch_sync(dispatch_get_main_queue(), ^{
337
+ [self stopBatchUploadTimer];
338
+ });
339
+ }
340
+
341
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
342
+
343
+ @try {
344
+ [self performStateSync:^{
345
+ [self.sessionEvents removeAllObjects];
346
+ }];
347
+ } @catch (NSException *exception) {
348
+ }
349
+ }
350
+
351
+ #pragma mark - React Native Methods
352
+
353
+ RCT_EXPORT_METHOD(startSession : (NSString *)userId apiUrl : (NSString *)
354
+ apiUrl publicKey : (NSString *)
355
+ publicKey resolve : (RCTPromiseResolveBlock)
356
+ resolve reject : (RCTPromiseRejectBlock)reject) {
357
+
358
+ RJLogInfo(@"[RJ-SESSION] startSession called from JS (userId=%@, apiUrl=%@)",
359
+ userId, apiUrl);
360
+
361
+ if (self.isShuttingDown) {
362
+ if (resolve) {
363
+ resolve(@{
364
+ @"success" : @NO,
365
+ @"sessionId" : @"",
366
+ @"error" : @"Module is shutting down"
367
+ });
368
+ }
369
+ return;
370
+ }
371
+
372
+ RCTPromiseResolveBlock safeResolve = [resolve copy];
373
+
374
+ dispatch_async(dispatch_get_main_queue(), ^{
375
+ if (self.isShuttingDown) {
376
+ if (safeResolve) {
377
+ safeResolve(@{
378
+ @"success" : @NO,
379
+ @"sessionId" : @"",
380
+ @"error" : @"Module is shutting down"
381
+ });
382
+ }
383
+ return;
384
+ }
385
+
386
+ @try {
387
+
388
+ [self ensureFullyInitialized];
389
+
390
+ if (self.isRecording) {
391
+ NSString *sessionId = self.currentSessionId;
392
+ if (safeResolve) {
393
+ safeResolve(@{@"success" : @YES, @"sessionId" : sessionId ?: @""});
394
+ }
395
+ return;
396
+ }
397
+
398
+ NSString *safeUserId = userId.length > 0 ? userId : @"anonymous";
399
+ NSString *safeApiUrl =
400
+ apiUrl.length > 0 ? apiUrl : @"https://api.rejourney.co";
401
+ NSString *safePublicKey = publicKey.length > 0 ? publicKey : @"";
402
+
403
+ NSString *vendorId =
404
+ [[[UIDevice currentDevice] identifierForVendor] UUIDString]
405
+ ?: @"unknown";
406
+ NSData *vendorData = [vendorId dataUsingEncoding:NSUTF8StringEncoding];
407
+
408
+ unsigned char hash[CC_SHA256_DIGEST_LENGTH];
409
+ CC_SHA256(vendorData.bytes, (CC_LONG)vendorData.length, hash);
410
+
411
+ NSMutableString *deviceHash =
412
+ [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
413
+ for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) {
414
+ [deviceHash appendFormat:@"%02x", hash[i]];
415
+ }
416
+
417
+ [self performStateSync:^{
418
+ self.userId = safeUserId;
419
+ self.currentSessionId = [RJWindowUtils generateSessionId];
420
+ self.sessionStartTime = [[NSDate date] timeIntervalSince1970];
421
+ self.totalBackgroundTimeMs = 0;
422
+ [self.sessionEvents removeAllObjects];
423
+
424
+ NSString *pendingPath =
425
+ [NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
426
+ NSUserDomainMask, YES)
427
+ .firstObject stringByAppendingPathComponent:@"rj_pending"];
428
+ self.eventBuffer =
429
+ [[RJEventBuffer alloc] initWithSessionId:self.currentSessionId
430
+ pendingRootPath:pendingPath];
431
+
432
+ if (self.currentSessionId) {
433
+ [[NSUserDefaults standardUserDefaults]
434
+ setObject:self.currentSessionId
435
+ forKey:@"rj_current_session_id"];
436
+ [[NSUserDefaults standardUserDefaults] synchronize];
437
+ }
438
+ }];
439
+
440
+ if (self.uploadManager) {
441
+ self.uploadManager.apiUrl = safeApiUrl;
442
+ self.uploadManager.publicKey = safePublicKey;
443
+ self.uploadManager.deviceHash = deviceHash;
444
+ self.uploadManager.sessionId = self.currentSessionId;
445
+ self.uploadManager.userId = safeUserId;
446
+ self.uploadManager.sessionStartTime = self.sessionStartTime;
447
+ }
448
+
449
+ self.recordingEnabledByConfig = YES;
450
+ self.sampleRate = 100;
451
+ self.hasProjectConfig = NO;
452
+ [self resetSamplingDecision];
453
+
454
+ self.remoteRecordingEnabled = YES;
455
+ self.remoteBillingBlocked = NO;
456
+ if (self.captureEngine && self.remoteRecordingEnabled) {
457
+ RJLogInfo(@"[RJ-VIDEO] Configuring segment uploader from Rejourney.mm");
458
+ RJLogInfo(@"[RJ-VIDEO] apiUrl=%@, publicKey=%@", safeApiUrl,
459
+ safePublicKey ? @"<set>" : @"<nil>");
460
+ RJLogInfo(@"[RJ-VIDEO] captureEngine=%@", self.captureEngine);
461
+
462
+ [self.captureEngine configureSegmentUploaderWithBaseURL:safeApiUrl
463
+ apiKey:safePublicKey
464
+ projectId:safePublicKey];
465
+
466
+ RJLogInfo(@"[RJ-VIDEO] Calling startSessionWithId: %@",
467
+ self.currentSessionId);
468
+ [self.captureEngine startSessionWithId:self.currentSessionId];
469
+ } else {
470
+ RJLogInfo(@"[RJ-VIDEO] NOT starting capture: captureEngine=%@, "
471
+ @"remoteRecordingEnabled=%d",
472
+ self.captureEngine, self.remoteRecordingEnabled);
473
+ }
474
+
475
+ self.isRecording = YES;
476
+
477
+ if (self.lifecycleManager) {
478
+ self.lifecycleManager.isRecording = YES;
479
+ }
480
+
481
+ [self setupDeviceAuthWithPublicKey:safePublicKey apiUrl:safeApiUrl];
482
+
483
+ [self startBatchUploadTimer];
484
+ [self startDurationLimitTimer];
485
+
486
+ if (_rj_constructorMachTime > 0) {
487
+
488
+ uint64_t nowMachTime = mach_absolute_time();
489
+ uint64_t elapsedMachTime = nowMachTime - _rj_constructorMachTime;
490
+
491
+ mach_timebase_info_data_t timebase;
492
+ mach_timebase_info(&timebase);
493
+ NSTimeInterval elapsedNs =
494
+ (double)elapsedMachTime * timebase.numer / timebase.denom;
495
+ NSTimeInterval startupDurationMs = elapsedNs / 1000000.0;
496
+
497
+ if (startupDurationMs > 0 && startupDurationMs < 60000) {
498
+ [self logEventInternal:@"app_startup"
499
+ details:@{
500
+ @"durationMs" : @(startupDurationMs),
501
+ @"platform" : @"ios"
502
+ }];
503
+ RJLogDebug(@"Recorded app startup time: %.0fms", startupDurationMs);
504
+ }
505
+ }
506
+
507
+ [self.uploadManager fetchProjectConfigWithCompletion:^(
508
+ BOOL success, NSDictionary *config,
509
+ NSError *error) {
510
+ if (!success)
511
+ return;
512
+
513
+ dispatch_async(dispatch_get_main_queue(), ^{
514
+ self.hasProjectConfig = YES;
515
+ NSInteger sampleRate = self.sampleRate;
516
+
517
+ if (config[@"sampleRate"]) {
518
+ sampleRate = [config[@"sampleRate"] integerValue];
519
+ }
520
+
521
+ if (config[@"maxRecordingMinutes"]) {
522
+ self.maxRecordingMinutes =
523
+ [config[@"maxRecordingMinutes"] integerValue];
524
+ RJLogDebug(@"Updated maxRecordingMinutes to %ld minutes",
525
+ (long)self.maxRecordingMinutes);
526
+ [self startDurationLimitTimer];
527
+ }
528
+
529
+ if (config[@"rejourneyEnabled"] &&
530
+ ![config[@"rejourneyEnabled"] boolValue]) {
531
+ RJLogWarning(
532
+ @"Rejourney disabled by remote config, stopping session");
533
+ self.remoteRejourneyEnabled = NO;
534
+ [self stopSessionInternal];
535
+ return;
536
+ }
537
+ self.remoteRejourneyEnabled = YES;
538
+
539
+ self.remoteBillingBlocked = NO;
540
+ self.recordingEnabledByConfig = YES;
541
+
542
+ if (config[@"billingBlocked"] &&
543
+ [config[@"billingBlocked"] boolValue]) {
544
+ RJLogWarning(
545
+ @"Session limit reached - recording blocked by billing");
546
+ self.remoteBillingBlocked = YES;
547
+ self.recordingEnabledByConfig = NO;
548
+ }
549
+
550
+ if (config[@"recordingEnabled"] &&
551
+ ![config[@"recordingEnabled"] boolValue]) {
552
+ RJLogWarning(
553
+ @"Recording disabled by remote config, stopping capture only");
554
+ self.recordingEnabledByConfig = NO;
555
+ }
556
+
557
+ [self updateRecordingEligibilityWithSampleRate:sampleRate];
558
+ });
559
+ }];
560
+
561
+ NSString *sessionId = self.currentSessionId;
562
+
563
+ [RJLogger logSessionStart:sessionId];
564
+
565
+ RJLogInfo(@"[RJ-SESSION] ✅ Session started successfully (sessionId=%@, "
566
+ @"isRecording=%d)",
567
+ sessionId, self.isRecording);
568
+
569
+ if (safeResolve) {
570
+ safeResolve(@{@"success" : @YES, @"sessionId" : sessionId ?: @""});
571
+ }
572
+ } @catch (NSException *exception) {
573
+ RJLogError(@"Failed to start session: %@", exception);
574
+ self.isRecording = NO;
575
+
576
+ if (safeResolve) {
577
+ safeResolve(@{
578
+ @"success" : @NO,
579
+ @"sessionId" : @"",
580
+ @"error" : exception.reason ?: @"Unknown error"
581
+ });
582
+ }
583
+ }
584
+ });
585
+ }
586
+
587
+ RCT_EXPORT_METHOD(debugCrash) {
588
+ RCTLogInfo(@"[Rejourney] Triggering debug crash...");
589
+
590
+ dispatch_async(dispatch_get_main_queue(), ^{
591
+ [NSException raise:@"RJDebugCrashException"
592
+ format:@"This is a test crash triggered from React Native"];
593
+ });
594
+ }
595
+
596
+ RCT_EXPORT_METHOD(debugTriggerANR : (double)durationMs) {
597
+
598
+ NSTimeInterval anrThreshold = [RJANRHandler sharedInstance].threshold;
599
+ NSTimeInterval minDurationMs = (anrThreshold * 1000.0) + 500.0;
600
+ NSTimeInterval actualDurationMs = MAX(durationMs, minDurationMs);
601
+
602
+ RCTLogInfo(@"[Rejourney] Triggering debug ANR for %.0fms (requested %.0fms, "
603
+ @"threshold %.0fms)...",
604
+ actualDurationMs, durationMs, anrThreshold * 1000.0);
605
+
606
+ dispatch_async(dispatch_get_main_queue(), ^{
607
+ [NSThread sleepForTimeInterval:actualDurationMs / 1000.0];
608
+ });
609
+ }
610
+
611
+ RCT_EXPORT_METHOD(getSessionId : (RCTPromiseResolveBlock)
612
+ resolve reject : (RCTPromiseRejectBlock)reject) {
613
+ NSString *sessionId = self.currentSessionId;
614
+
615
+ if (sessionId) {
616
+ resolve(sessionId);
617
+ } else {
618
+ resolve([NSNull null]);
619
+ }
620
+ }
621
+
622
+ RCT_EXPORT_METHOD(getSDKMetrics : (RCTPromiseResolveBlock)
623
+ resolve reject : (RCTPromiseRejectBlock)reject) {
624
+
625
+ NSDictionary *metrics = [[RJTelemetry sharedInstance] metricsAsDictionary];
626
+ resolve(metrics ?: @{});
627
+ }
628
+
629
+ RCT_EXPORT_METHOD(stopSession : (RCTPromiseResolveBlock)
630
+ resolve reject : (RCTPromiseRejectBlock)reject) {
631
+
632
+ RCTPromiseResolveBlock safeResolve = [resolve copy];
633
+
634
+ dispatch_async(dispatch_get_main_queue(), ^{
635
+ @try {
636
+ if (!self.isRecording) {
637
+ if (safeResolve)
638
+ safeResolve(@{@"success" : @YES, @"sessionId" : @""});
639
+ return;
640
+ }
641
+
642
+ self.isRecording = NO;
643
+
644
+ if (self.lifecycleManager) {
645
+ self.lifecycleManager.isRecording = NO;
646
+ }
647
+
648
+ NSString *sessionId = self.currentSessionId ?: @"";
649
+
650
+ // CRITICAL: Compute background time at the moment we end the session.
651
+ // When a session ends while the app is in background (e.g. max duration),
652
+ // we must include the ongoing background duration, otherwise the backend
653
+ // will think the entire wall-clock session duration is playable.
654
+ NSTimeInterval totalBgTimeMs = 0;
655
+ if (self.lifecycleManager) {
656
+ totalBgTimeMs = self.lifecycleManager.totalBackgroundTimeMs;
657
+ if (self.lifecycleManager.isInBackground &&
658
+ self.lifecycleManager.backgroundEntryTime > 0) {
659
+ NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
660
+ NSTimeInterval currentBgDurationMs =
661
+ (now - self.lifecycleManager.backgroundEntryTime) * 1000.0;
662
+ totalBgTimeMs += currentBgDurationMs;
663
+ RJLogInfo(@"[RJ-SESSION-END] stopSession adding current background "
664
+ @"duration: %.0fms, total: %.0fms",
665
+ currentBgDurationMs, totalBgTimeMs);
666
+ }
667
+ }
668
+ self.totalBackgroundTimeMs = totalBgTimeMs;
669
+
670
+ [RJLogger logSessionEnd:sessionId];
671
+
672
+ [self stopBatchUploadTimer];
673
+ [self stopDurationLimitTimer];
674
+
675
+ if (self.captureEngine) {
676
+ @try {
677
+ [self.captureEngine stopSession];
678
+ } @catch (NSException *captureException) {
679
+ RJLogWarning(@"Capture engine stop exception: %@", captureException);
680
+ }
681
+ }
682
+
683
+ [self logEventInternal:RJEventTypeSessionEnd
684
+ details:@{
685
+ @"totalBackgroundTime" : @(self.totalBackgroundTimeMs)
686
+ }];
687
+
688
+ if (self.uploadManager) {
689
+ self.uploadManager.totalBackgroundTimeMs = self.totalBackgroundTimeMs;
690
+ }
691
+
692
+ __block BOOL flushCompleted = NO;
693
+
694
+ [self flushAllDataWithCompletion:^(BOOL success) {
695
+ flushCompleted = YES;
696
+ if (safeResolve)
697
+ safeResolve(@{
698
+ @"success" : @YES,
699
+ @"sessionId" : sessionId,
700
+ @"uploadSuccess" : @(success)
701
+ });
702
+ }];
703
+
704
+ dispatch_after(
705
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(10.0 * NSEC_PER_SEC)),
706
+ dispatch_get_main_queue(), ^{
707
+ if (!flushCompleted && safeResolve) {
708
+ RJLogWarning(@"Session flush timed out");
709
+ safeResolve(@{
710
+ @"success" : @YES,
711
+ @"sessionId" : sessionId,
712
+ @"uploadSuccess" : @NO,
713
+ @"warning" : @"Flush timed out"
714
+ });
715
+ }
716
+ });
717
+ } @catch (NSException *exception) {
718
+ RJLogError(@"Failed to stop session: %@", exception);
719
+ self.isRecording = NO;
720
+ if (self.lifecycleManager) {
721
+ self.lifecycleManager.isRecording = NO;
722
+ }
723
+ if (safeResolve)
724
+ safeResolve(@{
725
+ @"success" : @NO,
726
+ @"error" : exception.reason ?: @"Unknown error"
727
+ });
728
+ }
729
+ });
730
+ }
731
+
732
+ RCT_EXPORT_METHOD(logEvent : (NSString *)eventType details : (NSDictionary *)
733
+ details resolve : (RCTPromiseResolveBlock)
734
+ resolve reject : (RCTPromiseRejectBlock)reject) {
735
+ dispatch_async(dispatch_get_main_queue(), ^{
736
+ @try {
737
+ if (self.isRecording) {
738
+ [self logEventInternal:eventType details:details];
739
+ }
740
+ resolve(@{@"success" : @YES});
741
+ } @catch (NSException *exception) {
742
+ resolve(@{@"success" : @YES});
743
+ }
744
+ });
745
+ }
746
+
747
+ RCT_EXPORT_METHOD(screenChanged : (NSString *)screenName resolve : (
748
+ RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
749
+ dispatch_async(dispatch_get_main_queue(), ^{
750
+ @try {
751
+ if (!self.isRecording) {
752
+ resolve(@{@"success" : @YES});
753
+ return;
754
+ }
755
+
756
+ [RJViewControllerTracker setAuthoritativeScreenName:screenName];
757
+
758
+ [self logEventInternal:RJEventTypeNavigation
759
+ details:@{@"screen" : screenName, @"source" : @"js"}];
760
+
761
+ [self.captureEngine notifyNavigationToScreen:screenName];
762
+ [self.captureEngine notifyReactNativeCommit];
763
+
764
+ resolve(@{@"success" : @YES});
765
+ } @catch (NSException *exception) {
766
+ resolve(@{@"success" : @YES});
767
+ }
768
+ });
769
+ }
770
+
771
+ RCT_EXPORT_METHOD(onScroll : (nonnull NSNumber *)offsetY resolve : (
772
+ RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
773
+
774
+ resolve(@{@"success" : @YES});
775
+ }
776
+
777
+ RCT_EXPORT_METHOD(markVisualChange : (NSString *)reason importance : (
778
+ NSString *)importanceString resolve : (RCTPromiseResolveBlock)
779
+ resolve reject : (RCTPromiseRejectBlock)reject) {
780
+ dispatch_async(dispatch_get_main_queue(), ^{
781
+ @try {
782
+ if (!self.isRecording) {
783
+ resolve(@YES);
784
+ return;
785
+ }
786
+
787
+ [self logEventInternal:RJEventTypeVisualChange
788
+ details:@{
789
+ @"reason" : reason,
790
+ @"importance" : importanceString
791
+ }];
792
+
793
+ if (self.captureEngine) {
794
+ [self.captureEngine notifyReactNativeCommit];
795
+ }
796
+
797
+ resolve(@YES);
798
+ } @catch (NSException *exception) {
799
+ resolve(@YES);
800
+ }
801
+ });
802
+ }
803
+
804
+ RCT_EXPORT_METHOD(onExternalURLOpened : (NSString *)urlScheme resolve : (
805
+ RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
806
+ dispatch_async(dispatch_get_main_queue(), ^{
807
+ @try {
808
+ if (!self.isRecording) {
809
+ resolve(@{@"success" : @YES});
810
+ return;
811
+ }
812
+
813
+ self.didOpenExternalURL = YES;
814
+ self.lastOpenedURLScheme = urlScheme;
815
+
816
+ [self logEventInternal:RJEventTypeExternalURLOpened
817
+ details:@{@"scheme" : urlScheme ?: @"unknown"}];
818
+
819
+ resolve(@{@"success" : @YES});
820
+ } @catch (NSException *exception) {
821
+ resolve(@{@"success" : @YES});
822
+ }
823
+ });
824
+ }
825
+
826
+ RCT_EXPORT_METHOD(onOAuthStarted : (NSString *)provider resolve : (
827
+ RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
828
+ dispatch_async(dispatch_get_main_queue(), ^{
829
+ @try {
830
+ if (!self.isRecording) {
831
+ resolve(@{@"success" : @YES});
832
+ return;
833
+ }
834
+
835
+ self.didOpenExternalURL = YES;
836
+ self.lastOpenedURLScheme =
837
+ [NSString stringWithFormat:@"oauth_%@", provider];
838
+
839
+ [self logEventInternal:RJEventTypeOAuthStarted
840
+ details:@{@"provider" : provider ?: @"unknown"}];
841
+
842
+ resolve(@{@"success" : @YES});
843
+ } @catch (NSException *exception) {
844
+ resolve(@{@"success" : @YES});
845
+ }
846
+ });
847
+ }
848
+
849
+ RCT_EXPORT_METHOD(onOAuthCompleted : (NSString *)provider success : (BOOL)
850
+ success resolve : (RCTPromiseResolveBlock)
851
+ resolve reject : (RCTPromiseRejectBlock)reject) {
852
+ dispatch_async(dispatch_get_main_queue(), ^{
853
+ @try {
854
+ if (!self.isRecording) {
855
+ resolve(@{@"success" : @YES});
856
+ return;
857
+ }
858
+
859
+ [self logEventInternal:RJEventTypeOAuthCompleted
860
+ details:@{
861
+ @"provider" : provider ?: @"unknown",
862
+ @"success" : @(success)
863
+ }];
864
+
865
+ resolve(@{@"success" : @YES});
866
+ } @catch (NSException *exception) {
867
+ resolve(@{@"success" : @YES});
868
+ }
869
+ });
870
+ }
871
+
872
+ #pragma mark - Privacy / View Masking
873
+
874
+ RCT_EXPORT_METHOD(maskViewByNativeID : (NSString *)nativeID resolve : (
875
+ RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
876
+ dispatch_async(dispatch_get_main_queue(), ^{
877
+ @try {
878
+ if (!nativeID || nativeID.length == 0) {
879
+ resolve(@{@"success" : @NO});
880
+ return;
881
+ }
882
+
883
+ [self.captureEngine.privacyMask addMaskedNativeID:nativeID];
884
+ RJLogDebug(@"Masked nativeID: %@", nativeID);
885
+ resolve(@{@"success" : @YES});
886
+ } @catch (NSException *exception) {
887
+ RJLogWarning(@"maskViewByNativeID failed: %@", exception);
888
+ resolve(@{@"success" : @NO});
889
+ }
890
+ });
891
+ }
892
+
893
+ RCT_EXPORT_METHOD(unmaskViewByNativeID : (NSString *)nativeID resolve : (
894
+ RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
895
+ dispatch_async(dispatch_get_main_queue(), ^{
896
+ @try {
897
+ if (!nativeID || nativeID.length == 0) {
898
+ resolve(@{@"success" : @NO});
899
+ return;
900
+ }
901
+
902
+ [self.captureEngine.privacyMask removeMaskedNativeID:nativeID];
903
+ RJLogDebug(@"Unmasked nativeID: %@", nativeID);
904
+ resolve(@{@"success" : @YES});
905
+ } @catch (NSException *exception) {
906
+ RJLogWarning(@"unmaskViewByNativeID failed: %@", exception);
907
+ resolve(@{@"success" : @NO});
908
+ }
909
+ });
910
+ }
911
+
912
+ - (UIView *)findViewWithNativeID:(NSString *)nativeID inView:(UIView *)view {
913
+ if (!view || !nativeID)
914
+ return nil;
915
+
916
+ if ([view.accessibilityIdentifier isEqualToString:nativeID]) {
917
+ return view;
918
+ }
919
+
920
+ for (UIView *subview in view.subviews) {
921
+ UIView *found = [self findViewWithNativeID:nativeID inView:subview];
922
+ if (found)
923
+ return found;
924
+ }
925
+
926
+ return nil;
927
+ }
928
+
929
+ #pragma mark - Debug Mode
930
+
931
+ RCT_EXPORT_METHOD(setDebugMode : (BOOL)enabled resolve : (
932
+ RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
933
+ @try {
934
+ [RJLogger setDebugMode:enabled];
935
+ resolve(@{@"success" : @YES});
936
+ } @catch (NSException *exception) {
937
+ RJLogWarning(@"setDebugMode failed: %@", exception);
938
+ resolve(@{@"success" : @NO});
939
+ }
940
+ }
941
+
942
+ #pragma mark - User Identity
943
+
944
+ RCT_EXPORT_METHOD(setUserIdentity : (NSString *)userId resolve : (
945
+ RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
946
+ dispatch_async(dispatch_get_main_queue(), ^{
947
+ @try {
948
+ NSString *safeUserId = userId.length > 0 ? userId : @"anonymous";
949
+
950
+ [self performStateSync:^{
951
+ self.userId = safeUserId;
952
+ }];
953
+
954
+ if (self.uploadManager) {
955
+ self.uploadManager.userId = safeUserId;
956
+ }
957
+
958
+ RJLogDebug(@"User identity updated: %@", safeUserId);
959
+
960
+ if (self.isRecording) {
961
+ [self logEventInternal:@"user_identity_changed"
962
+ details:@{@"userId" : safeUserId}];
963
+ }
964
+
965
+ resolve(@{@"success" : @YES});
966
+ } @catch (NSException *exception) {
967
+ RJLogWarning(@"setUserIdentity failed: %@", exception);
968
+ resolve(@{@"success" : @NO});
969
+ }
970
+ });
971
+ }
972
+
973
+ #pragma mark - RJTouchInterceptorDelegate
974
+
975
+ - (void)touchInterceptorDidDetectInteractionStart {
976
+ }
977
+
978
+ - (void)touchInterceptorDidRecognizeGesture:(NSString *)gestureType
979
+ touches:(NSArray<NSDictionary *> *)touches
980
+ duration:(NSTimeInterval)duration
981
+ targetLabel:(NSString *)targetLabel {
982
+
983
+ if (!self.isRecording || self.isShuttingDown) {
984
+ return;
985
+ }
986
+ if (!gestureType) {
987
+ return;
988
+ }
989
+
990
+ // Throttle scroll events to avoid spamming the main thread/logs
991
+ if ([gestureType hasPrefix:@"scroll"]) {
992
+ static NSTimeInterval lastScrollLogTime = 0;
993
+ NSTimeInterval now = CACurrentMediaTime();
994
+ if (now - lastScrollLogTime < 0.5) { // 500ms throttle
995
+ return;
996
+ }
997
+ lastScrollLogTime = now;
998
+ }
999
+
1000
+ // Move ALL processing to the background state queue to unblock Main Thread
1001
+ dispatch_async(self.stateQueue, ^{
1002
+ @try {
1003
+ NSMutableDictionary *details =
1004
+ [NSMutableDictionary dictionaryWithDictionary:@{
1005
+ @"gestureType" : gestureType ?: @"unknown",
1006
+ @"duration" : @(duration),
1007
+ @"touchCount" : @(touches.count),
1008
+ @"touches" : touches ?: @[],
1009
+ @"targetLabel" : targetLabel ?: [NSNull null]
1010
+ }];
1011
+
1012
+ // Log internal event (already on stateQueue, so safe)
1013
+ // We call a simpler version that assumes we are already on background or
1014
+ // handles it Actually logEventInternal dispatches TO stateQueue. Since we
1015
+ // are ON stateQueue, we can call a direct helper or just logEventInternal
1016
+ // (it will just dispatch_async again which is fine) But to be cleaner,
1017
+ // let's just use logEventInternal but ensuring we don't do main thread
1018
+ // dictionary work above. We moved the dictionary creation HERE
1019
+ // (background).
1020
+
1021
+ [self logEventInternal:RJEventTypeGesture details:[details copy]];
1022
+
1023
+ // Notify engine on main thread (it's lightweight)
1024
+ dispatch_async(dispatch_get_main_queue(), ^{
1025
+ if (self.captureEngine) {
1026
+ [self.captureEngine notifyGesture:gestureType];
1027
+ }
1028
+ });
1029
+
1030
+ } @catch (NSException *exception) {
1031
+ RJLogWarning(@"Gesture handling failed: %@", exception);
1032
+ }
1033
+ });
1034
+ }
1035
+
1036
+ - (BOOL)isCurrentlyRecording {
1037
+ return self.isRecording;
1038
+ }
1039
+
1040
+ - (BOOL)isKeyboardCurrentlyVisible {
1041
+ return self.lifecycleManager.isKeyboardVisible;
1042
+ }
1043
+
1044
+ - (CGRect)currentKeyboardFrame {
1045
+ return self.lifecycleManager.keyboardFrame;
1046
+ }
1047
+
1048
+ - (void)touchInterceptorDidCaptureMotionEvent:(RJMotionEvent *)motionEvent {
1049
+
1050
+ if (!self.isRecording || self.isShuttingDown)
1051
+ return;
1052
+ if (!motionEvent)
1053
+ return;
1054
+
1055
+ @try {
1056
+
1057
+ NSMutableDictionary *details = [NSMutableDictionary dictionary];
1058
+ details[@"motionType"] = [motionEvent typeName];
1059
+ details[@"t0"] = @(motionEvent.t0);
1060
+ details[@"t1"] = @(motionEvent.t1);
1061
+ details[@"dx"] = @(motionEvent.dx);
1062
+ details[@"dy"] = @(motionEvent.dy);
1063
+ details[@"v0"] = @(motionEvent.v0);
1064
+ details[@"v1"] = @(motionEvent.v1);
1065
+ details[@"curve"] = [motionEvent curveName];
1066
+ if (motionEvent.targetId) {
1067
+ details[@"targetId"] = motionEvent.targetId;
1068
+ }
1069
+
1070
+ [self logEventInternal:@"motion" details:details];
1071
+
1072
+ RJLogDebug(@"Motion event logged: %@ dx=%.1f dy=%.1f v0=%.1f",
1073
+ [motionEvent typeName], motionEvent.dx, motionEvent.dy,
1074
+ motionEvent.v0);
1075
+ } @catch (NSException *exception) {
1076
+ RJLogWarning(@"Motion event handling failed: %@", exception);
1077
+ }
1078
+ }
1079
+
1080
+ #pragma mark - Device Authentication
1081
+
1082
+ - (void)setupDeviceAuthWithPublicKey:(NSString *)publicKey
1083
+ apiUrl:(NSString *)apiUrl {
1084
+ RJLogDebug(@"Registering device for authentication...");
1085
+ RJDeviceAuthManager *deviceAuth = [RJDeviceAuthManager sharedManager];
1086
+ NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier] ?: @"unknown";
1087
+
1088
+ __weak __typeof__(self) weakSelf = self;
1089
+ [deviceAuth
1090
+ registerDeviceWithProjectKey:publicKey
1091
+ bundleId:bundleId
1092
+ platform:@"ios"
1093
+ sdkVersion:@"1.0.0"
1094
+ apiUrl:apiUrl
1095
+ completion:^(BOOL success, NSString *credId,
1096
+ NSError *error) {
1097
+ __strong __typeof__(weakSelf) strongSelf = weakSelf;
1098
+ if (!strongSelf)
1099
+ return;
1100
+
1101
+ if (!success) {
1102
+ RJLogError(@"Device registration failed: %@",
1103
+ error);
1104
+
1105
+ // SECURITY: Handle specific error codes
1106
+ // 403 = Bundle ID mismatch or forbidden - PERMANENT
1107
+ // failure 404 = Project not found - could be
1108
+ // temporary, retry Other = Network/transient -
1109
+ // retry
1110
+ if (error.code == 403) {
1111
+ RJLogError(@"SECURITY: Bundle ID mismatch or "
1112
+ @"access forbidden. "
1113
+ @"Stopping recording to prevent data "
1114
+ @"leakage.");
1115
+ strongSelf.authPermanentlyFailed = YES;
1116
+ [strongSelf handleAuthenticationFailure:error];
1117
+ } else {
1118
+ // For 404 and other errors, schedule retry with
1119
+ // exponential backoff Recording continues locally
1120
+ // - events queued for later upload
1121
+ [strongSelf scheduleAuthRetryWithError:error
1122
+ publicKey:publicKey
1123
+ apiUrl:apiUrl];
1124
+ }
1125
+ } else {
1126
+ RJLogDebug(@"Device registered: %@", credId);
1127
+
1128
+ // Auth succeeded - reset retry state
1129
+ [strongSelf resetAuthRetryState];
1130
+
1131
+ [strongSelf
1132
+ handleUploadTokenFetchWithDeviceAuth:deviceAuth
1133
+ publicKey:publicKey
1134
+ apiUrl:apiUrl
1135
+ isRetry:NO];
1136
+ }
1137
+ }];
1138
+ }
1139
+
1140
+ - (void)handleAuthenticationFailure:(NSError *)error {
1141
+ RJLogError(@"Authentication failure - stopping recording. Error: %@", error);
1142
+
1143
+ // Stop recording to prevent data accumulation that can't be uploaded
1144
+ dispatch_async(dispatch_get_main_queue(), ^{
1145
+ if (self.isRecording) {
1146
+ self.isRecording = NO;
1147
+
1148
+ // Stop capture engine
1149
+ if (self.captureEngine) {
1150
+ [self.captureEngine stopSession];
1151
+ }
1152
+
1153
+ // Clear auth data so next attempt starts fresh
1154
+ [[RJDeviceAuthManager sharedManager] clearAllAuthData];
1155
+
1156
+ // Notify JS layer about the failure (if bridge is available)
1157
+ // This allows the app to handle the error (e.g., show user message)
1158
+ @try {
1159
+ if (self.bridge) {
1160
+ [self.bridge enqueueJSCall:@"RCTDeviceEventEmitter"
1161
+ method:@"emit"
1162
+ args:@[
1163
+ @"rejourneyAuthError", @{
1164
+ @"code" : @(error.code),
1165
+ @"message" : error.localizedDescription
1166
+ ?: @"Authentication failed",
1167
+ @"domain" : error.domain ?: @"RJDeviceAuth"
1168
+ }
1169
+ ]
1170
+ completion:nil];
1171
+ }
1172
+ } @catch (NSException *exception) {
1173
+ RJLogWarning(@"Failed to notify JS about auth error: %@", exception);
1174
+ }
1175
+ }
1176
+ });
1177
+ }
1178
+
1179
+ #pragma mark - Auth Retry Mechanism
1180
+
1181
+ - (void)scheduleAuthRetryWithError:(NSError *)error
1182
+ publicKey:(NSString *)publicKey
1183
+ apiUrl:(NSString *)apiUrl {
1184
+ // Check if already permanently failed (403 security error)
1185
+ if (self.authPermanentlyFailed) {
1186
+ RJLogWarning(@"Auth permanently failed - not scheduling retry");
1187
+ return;
1188
+ }
1189
+
1190
+ // Increment retry count
1191
+ self.authRetryCount++;
1192
+
1193
+ // Check max retries
1194
+ if (self.authRetryCount > RJ_MAX_AUTH_RETRIES) {
1195
+ RJLogError(@"Auth failed after %ld retries. Recording continues locally, "
1196
+ @"events will be uploaded when auth succeeds.",
1197
+ (long)RJ_MAX_AUTH_RETRIES);
1198
+
1199
+ // Notify JS but DON'T stop recording - events queue locally
1200
+ dispatch_async(dispatch_get_main_queue(), ^{
1201
+ @try {
1202
+ if (self.bridge) {
1203
+ [self.bridge enqueueJSCall:@"RCTDeviceEventEmitter"
1204
+ method:@"emit"
1205
+ args:@[
1206
+ @"rejourneyAuthWarning", @{
1207
+ @"code" : @(error.code),
1208
+ @"message" : @"Auth failed after max "
1209
+ @"retries. Recording locally.",
1210
+ @"retryCount" : @(self.authRetryCount)
1211
+ }
1212
+ ]
1213
+ completion:nil];
1214
+ }
1215
+ } @catch (NSException *exception) {
1216
+ // Ignore JS notification failures
1217
+ }
1218
+ });
1219
+
1220
+ // Schedule a much longer retry (5 minutes) to try again later
1221
+ [self scheduleBackgroundAuthRetryAfter:300.0
1222
+ publicKey:publicKey
1223
+ apiUrl:apiUrl];
1224
+ return;
1225
+ }
1226
+
1227
+ // Calculate exponential backoff delay: 2, 4, 8, 16, 32... capped at 60s
1228
+ NSTimeInterval delay =
1229
+ MIN(RJ_AUTH_RETRY_BASE_DELAY * pow(2, self.authRetryCount - 1),
1230
+ RJ_AUTH_RETRY_MAX_DELAY);
1231
+
1232
+ RJLogInfo(@"Auth failed (attempt %ld/%ld), retrying in %.1fs. "
1233
+ @"Recording continues locally. Error: %@",
1234
+ (long)self.authRetryCount, (long)RJ_MAX_AUTH_RETRIES, delay,
1235
+ error.localizedDescription);
1236
+
1237
+ [self scheduleBackgroundAuthRetryAfter:delay
1238
+ publicKey:publicKey
1239
+ apiUrl:apiUrl];
1240
+ }
1241
+
1242
+ - (void)scheduleBackgroundAuthRetryAfter:(NSTimeInterval)delay
1243
+ publicKey:(NSString *)publicKey
1244
+ apiUrl:(NSString *)apiUrl {
1245
+ // Cancel any existing retry timer
1246
+ if (self.authRetryTimer) {
1247
+ [self.authRetryTimer invalidate];
1248
+ self.authRetryTimer = nil;
1249
+ }
1250
+
1251
+ self.nextAuthRetryTime = [[NSDate date] timeIntervalSince1970] + delay;
1252
+
1253
+ __weak __typeof__(self) weakSelf = self;
1254
+ dispatch_async(dispatch_get_main_queue(), ^{
1255
+ __strong __typeof__(weakSelf) strongSelf = weakSelf;
1256
+ if (!strongSelf)
1257
+ return;
1258
+
1259
+ strongSelf.authRetryTimer = [NSTimer
1260
+ scheduledTimerWithTimeInterval:delay
1261
+ repeats:NO
1262
+ block:^(NSTimer *timer) {
1263
+ [strongSelf
1264
+ performAuthRetryWithPublicKey:publicKey
1265
+ apiUrl:apiUrl];
1266
+ }];
1267
+ });
1268
+ }
1269
+
1270
+ - (void)performAuthRetryWithPublicKey:(NSString *)publicKey
1271
+ apiUrl:(NSString *)apiUrl {
1272
+ if (self.authPermanentlyFailed || self.isShuttingDown) {
1273
+ return;
1274
+ }
1275
+
1276
+ RJLogInfo(@"Retrying auth (attempt %ld)...", (long)(self.authRetryCount + 1));
1277
+
1278
+ // After 2 failed attempts, clear cached auth data and re-register fresh
1279
+ // This handles expired/corrupted tokens or server-side revocations
1280
+ if (self.authRetryCount >= 2) {
1281
+ RJLogInfo(@"Clearing cached auth data and re-registering fresh...");
1282
+ [[RJDeviceAuthManager sharedManager] clearAllAuthData];
1283
+ }
1284
+
1285
+ // Re-attempt device auth setup
1286
+ [self setupDeviceAuthWithPublicKey:publicKey apiUrl:apiUrl];
1287
+ }
1288
+
1289
+ - (void)resetAuthRetryState {
1290
+ self.authRetryCount = 0;
1291
+ self.authPermanentlyFailed = NO;
1292
+ self.nextAuthRetryTime = 0;
1293
+
1294
+ if (self.authRetryTimer) {
1295
+ [self.authRetryTimer invalidate];
1296
+ self.authRetryTimer = nil;
1297
+ }
1298
+ }
1299
+
1300
+ - (void)handleUploadTokenFetchWithDeviceAuth:(RJDeviceAuthManager *)deviceAuth
1301
+ publicKey:(NSString *)publicKey
1302
+ apiUrl:(NSString *)apiUrl
1303
+ isRetry:(BOOL)isRetry {
1304
+ __weak __typeof__(self) weakSelf = self;
1305
+ [deviceAuth getUploadTokenWithCompletion:^(BOOL success, NSString *token,
1306
+ NSInteger expiresIn,
1307
+ NSError *error) {
1308
+ __strong __typeof__(weakSelf) strongSelf = weakSelf;
1309
+ if (!strongSelf)
1310
+ return;
1311
+
1312
+ if (success) {
1313
+ RJLogDebug(@"Got upload token (expires in %ld seconds)", (long)expiresIn);
1314
+
1315
+ [strongSelf.uploadManager recoverPendingSessionsWithCompletion:nil];
1316
+ [strongSelf handlePendingCrashReportUpload];
1317
+ [strongSelf handlePendingANRReportUpload];
1318
+ [strongSelf handlePendingVideoSegmentRecovery];
1319
+ return;
1320
+ }
1321
+
1322
+ // Handle security errors (403 = forbidden/mismatch, 404 = not found)
1323
+ if ((error.code == 403 || error.code == 404) &&
1324
+ [error.domain isEqualToString:@"RJDeviceAuth"]) {
1325
+
1326
+ if (!isRetry) {
1327
+ // First failure - try re-registration
1328
+ RJLogDebug(@"Device auth invalid (%ld), attempting re-registration...",
1329
+ (long)error.code);
1330
+ NSString *bundleId =
1331
+ [[NSBundle mainBundle] bundleIdentifier] ?: @"unknown";
1332
+
1333
+ __typeof__(strongSelf) nestedSelf = strongSelf;
1334
+ [deviceAuth
1335
+ registerDeviceWithProjectKey:publicKey
1336
+ bundleId:bundleId
1337
+ platform:@"ios"
1338
+ sdkVersion:@"1.0.0"
1339
+ apiUrl:apiUrl
1340
+ completion:^(BOOL retrySuccess, NSString *credId,
1341
+ NSError *retryError) {
1342
+ if (retrySuccess) {
1343
+ RJLogDebug(@"Re-registration successful, "
1344
+ @"retrying token fetch...");
1345
+ [nestedSelf
1346
+ handleUploadTokenFetchWithDeviceAuth:
1347
+ deviceAuth
1348
+ publicKey:
1349
+ publicKey
1350
+ apiUrl:
1351
+ apiUrl
1352
+ isRetry:YES];
1353
+ } else {
1354
+ // Re-registration also failed - this is a
1355
+ // security error
1356
+ RJLogError(@"Re-registration failed: %@",
1357
+ retryError);
1358
+ if (retryError.code == 403 ||
1359
+ retryError.code == 404) {
1360
+ [nestedSelf
1361
+ handleAuthenticationFailure:retryError];
1362
+ }
1363
+ }
1364
+ }];
1365
+ } else {
1366
+ // Already retried - this is a persistent security error
1367
+ RJLogError(@"Token fetch failed after retry: %@", error);
1368
+ [strongSelf handleAuthenticationFailure:error];
1369
+ }
1370
+ } else {
1371
+ // Network or other transient errors
1372
+ RJLogWarning(@"Failed to get upload token (transient): %@", error);
1373
+ }
1374
+ }];
1375
+ }
1376
+
1377
+ - (void)handlePendingCrashReportUpload {
1378
+ if (![[RJCrashHandler sharedInstance] hasPendingCrashReport])
1379
+ return;
1380
+
1381
+ RJLogDebug(@"Found pending crash report from previous session");
1382
+ NSDictionary *crashReport =
1383
+ [[RJCrashHandler sharedInstance] loadAndPurgePendingCrashReport];
1384
+ if (!crashReport)
1385
+ return;
1386
+
1387
+ NSMutableDictionary *augmentedReport = [crashReport mutableCopy];
1388
+ augmentedReport[@"projectId"] = self.uploadManager.projectId;
1389
+
1390
+ [self.uploadManager
1391
+ uploadCrashReport:augmentedReport
1392
+ completion:^(BOOL success) {
1393
+ if (success) {
1394
+ RJLogDebug(@"Pending crash report uploaded");
1395
+ } else {
1396
+ RJLogWarning(@"Failed to upload pending crash report");
1397
+ }
1398
+ }];
1399
+ }
1400
+
1401
+ - (void)handlePendingANRReportUpload {
1402
+ if (![[RJANRHandler sharedInstance] hasPendingANRReport])
1403
+ return;
1404
+
1405
+ RJLogDebug(@"Found pending ANR report from previous session");
1406
+ NSDictionary *anrReport =
1407
+ [[RJANRHandler sharedInstance] loadAndPurgePendingANRReport];
1408
+ if (!anrReport)
1409
+ return;
1410
+
1411
+ NSMutableDictionary *augmentedReport = [anrReport mutableCopy];
1412
+ augmentedReport[@"projectId"] = self.uploadManager.projectId;
1413
+ augmentedReport[@"type"] = @"anr";
1414
+
1415
+ [self.uploadManager
1416
+ uploadANRReport:augmentedReport
1417
+ completion:^(BOOL success) {
1418
+ if (success) {
1419
+ RJLogDebug(@"Pending ANR report uploaded");
1420
+ } else {
1421
+ RJLogWarning(@"Failed to upload pending ANR report");
1422
+ }
1423
+ }];
1424
+ }
1425
+
1426
+ - (void)handlePendingVideoSegmentRecovery {
1427
+ NSDictionary *metadata = [RJVideoEncoder pendingCrashSegmentMetadata];
1428
+ if (!metadata)
1429
+ return;
1430
+
1431
+ RJLogDebug(@"Found pending video segment from crash: %@", metadata);
1432
+
1433
+ NSString *segmentPath = metadata[@"segmentPath"];
1434
+ NSString *sessionId = metadata[@"sessionId"];
1435
+ NSNumber *startTime = metadata[@"startTime"];
1436
+ NSNumber *endTime = metadata[@"endTime"];
1437
+ NSNumber *frameCount = metadata[@"frameCount"];
1438
+ BOOL finalized = [metadata[@"finalized"] boolValue];
1439
+
1440
+ if (!segmentPath ||
1441
+ ![[NSFileManager defaultManager] fileExistsAtPath:segmentPath]) {
1442
+ RJLogWarning(@"Pending segment file not found at: %@", segmentPath);
1443
+ [RJVideoEncoder clearPendingCrashSegmentMetadata];
1444
+ return;
1445
+ }
1446
+
1447
+ if (!finalized) {
1448
+ RJLogWarning(@"Pending segment was not finalized, skipping upload");
1449
+
1450
+ [[NSFileManager defaultManager] removeItemAtPath:segmentPath error:nil];
1451
+ [RJVideoEncoder clearPendingCrashSegmentMetadata];
1452
+ return;
1453
+ }
1454
+
1455
+ if (self.captureEngine.segmentUploader && sessionId.length > 0) {
1456
+ NSURL *segmentURL = [NSURL fileURLWithPath:segmentPath];
1457
+
1458
+ RJLogDebug(@"Uploading recovered crash segment: %@", segmentPath);
1459
+ [self.captureEngine.segmentUploader
1460
+ uploadVideoSegment:segmentURL
1461
+ sessionId:sessionId
1462
+ startTime:[startTime doubleValue]
1463
+ endTime:[endTime doubleValue]
1464
+ frameCount:[frameCount integerValue]
1465
+ completion:^(BOOL success, NSError *error) {
1466
+ if (success) {
1467
+ RJLogDebug(
1468
+ @"Recovered crash segment uploaded successfully");
1469
+
1470
+ [[NSFileManager defaultManager] removeItemAtPath:segmentPath
1471
+ error:nil];
1472
+ } else {
1473
+ RJLogWarning(
1474
+ @"Failed to upload recovered crash segment: %@", error);
1475
+ }
1476
+
1477
+ [RJVideoEncoder clearPendingCrashSegmentMetadata];
1478
+ }];
1479
+ } else {
1480
+ RJLogWarning(@"Cannot upload recovered segment: uploader not configured or "
1481
+ @"no sessionId");
1482
+ [RJVideoEncoder clearPendingCrashSegmentMetadata];
1483
+ }
1484
+ }
1485
+
1486
+ #pragma mark - RJANRHandlerDelegate
1487
+
1488
+ - (void)anrDetectedWithDuration:(NSTimeInterval)duration
1489
+ threadState:(nullable NSString *)threadState {
1490
+ if (!self.isRecording)
1491
+ return;
1492
+
1493
+ RJLogDebug(@"ANR callback: duration=%.2fs", duration);
1494
+
1495
+ NSMutableDictionary *details = [NSMutableDictionary new];
1496
+ details[@"durationMs"] = @((NSInteger)(duration * 1000));
1497
+ if (threadState) {
1498
+ details[@"threadState"] = threadState;
1499
+ }
1500
+
1501
+ [self logEventInternal:@"anr" details:details];
1502
+
1503
+ if (self.uploadManager && self.currentSessionId &&
1504
+ self.currentSessionId.length > 0) {
1505
+ NSMutableDictionary *report = [NSMutableDictionary new];
1506
+ report[@"timestamp"] = @([[NSDate date] timeIntervalSince1970] * 1000);
1507
+ report[@"durationMs"] = @((NSInteger)(duration * 1000));
1508
+ report[@"type"] = @"anr";
1509
+ report[@"sessionId"] = self.currentSessionId;
1510
+ if (threadState) {
1511
+ report[@"threadState"] = threadState;
1512
+ }
1513
+
1514
+ __weak __typeof__(self) weakSelf = self;
1515
+ [self.uploadManager
1516
+ uploadANRReport:[report copy]
1517
+ completion:^(BOOL success) {
1518
+ __strong __typeof__(weakSelf) strongSelf = weakSelf;
1519
+ if (!strongSelf)
1520
+ return;
1521
+ if (success) {
1522
+ RJLogDebug(@"ANR report uploaded (live)");
1523
+ } else {
1524
+ RJLogWarning(@"ANR report upload failed (live)");
1525
+ }
1526
+ }];
1527
+ }
1528
+
1529
+ [[RJTelemetry sharedInstance] recordANR];
1530
+ }
1531
+
1532
+ #pragma mark - Event Logging
1533
+
1534
+ - (void)logEventInternal:(NSString *)eventType details:(NSDictionary *)details {
1535
+
1536
+ if (self.isShuttingDown || !eventType)
1537
+ return;
1538
+
1539
+ // Log gesture events specifically for debugging
1540
+ if ([eventType isEqualToString:@"gesture"]) {
1541
+ NSString *gestureType = details[@"gestureType"] ?: @"unknown";
1542
+ RJLogInfo(
1543
+ @"[RJ-EVENT] Logging gesture event: gestureType=%@, sessionId=%@, "
1544
+ @"eventBuffer=%@",
1545
+ gestureType, self.currentSessionId,
1546
+ self.eventBuffer ? @"exists" : @"nil");
1547
+ }
1548
+
1549
+ @try {
1550
+ NSMutableDictionary *event =
1551
+ [NSMutableDictionary dictionaryWithCapacity:10];
1552
+
1553
+ if (details) {
1554
+ @try {
1555
+ [event addEntriesFromDictionary:details];
1556
+ } @catch (NSException *e) {
1557
+ RJLogWarning(@"Failed to copy event details: %@", e);
1558
+ }
1559
+ }
1560
+
1561
+ event[@"type"] = eventType;
1562
+ event[@"timestamp"] = @([RJWindowUtils currentTimestampMillis]);
1563
+
1564
+ if (self.eventBuffer) {
1565
+ [self.eventBuffer appendEvent:event];
1566
+ }
1567
+
1568
+ dispatch_async(self.stateQueue, ^{
1569
+ @try {
1570
+ if (self.sessionEvents && !self.isShuttingDown) {
1571
+ [self.sessionEvents addObject:event];
1572
+ }
1573
+ } @catch (NSException *e) {
1574
+ RJLogWarning(@"Failed to add event: %@", e);
1575
+ }
1576
+ });
1577
+ } @catch (NSException *exception) {
1578
+ RJLogWarning(@"Event logging failed: %@", exception);
1579
+ }
1580
+ }
1581
+
1582
+ #pragma mark - Batch Upload Management
1583
+
1584
+ - (void)startBatchUploadTimer {
1585
+ [self stopBatchUploadTimer];
1586
+
1587
+ __weak __typeof__(self) weakSelf = self;
1588
+
1589
+ self.batchUploadTimer =
1590
+ [NSTimer scheduledTimerWithTimeInterval:RJBatchUploadInterval
1591
+ repeats:YES
1592
+ block:^(NSTimer *timer) {
1593
+ [weakSelf performBatchUploadIfNeeded];
1594
+ }];
1595
+
1596
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
1597
+ (int64_t)(RJInitialUploadDelay * NSEC_PER_SEC)),
1598
+ dispatch_get_main_queue(), ^{
1599
+ if (weakSelf.isRecording) {
1600
+ [weakSelf performBatchUploadIfNeeded];
1601
+ }
1602
+ });
1603
+ }
1604
+
1605
+ - (void)stopBatchUploadTimer {
1606
+ [self.batchUploadTimer invalidate];
1607
+ self.batchUploadTimer = nil;
1608
+ }
1609
+
1610
+ #pragma mark - Duration Limit Timer
1611
+
1612
+ - (void)startDurationLimitTimer {
1613
+ [self stopDurationLimitTimer];
1614
+
1615
+ if (self.maxRecordingMinutes <= 0) {
1616
+ return;
1617
+ }
1618
+
1619
+ NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
1620
+ NSTimeInterval elapsed = now - self.sessionStartTime;
1621
+ NSTimeInterval limitSeconds = self.maxRecordingMinutes * 60.0;
1622
+ NSTimeInterval remaining = limitSeconds - elapsed;
1623
+
1624
+ if (remaining <= 0) {
1625
+ RJLogInfo(@"Duration limit reached immediately (elapsed: %.1fs, limit: "
1626
+ @"%.1fs), stopping session",
1627
+ elapsed, limitSeconds);
1628
+ [self stopSessionDueToMaxDuration];
1629
+ return;
1630
+ }
1631
+
1632
+ RJLogDebug(
1633
+ @"Starting duration limit timer for %.1f seconds (limit: %ld mins)",
1634
+ remaining, (long)self.maxRecordingMinutes);
1635
+
1636
+ __weak __typeof__(self) weakSelf = self;
1637
+ self.durationLimitTimer = [NSTimer
1638
+ scheduledTimerWithTimeInterval:remaining
1639
+ repeats:NO
1640
+ block:^(NSTimer *_Nonnull timer) {
1641
+ [weakSelf stopSessionDueToMaxDuration];
1642
+ }];
1643
+ }
1644
+
1645
+ - (void)stopDurationLimitTimer {
1646
+ if (self.durationLimitTimer) {
1647
+ [self.durationLimitTimer invalidate];
1648
+ self.durationLimitTimer = nil;
1649
+ }
1650
+ }
1651
+
1652
+ - (void)stopSessionDueToMaxDuration {
1653
+ if (!self.isRecording)
1654
+ return;
1655
+
1656
+ RJLogDebug(@"Max recording duration reached (%ld minutes) - stopping session",
1657
+ (long)self.maxRecordingMinutes);
1658
+
1659
+ [self logEventInternal:RJEventTypeSessionEnd
1660
+ details:@{
1661
+ @"reason" : @"max_duration_reached",
1662
+ @"maxMinutes" : @(self.maxRecordingMinutes)
1663
+ }];
1664
+
1665
+ [self stopSessionInternal];
1666
+ }
1667
+
1668
+ - (void)stopSessionInternal {
1669
+ // Call the renamed method with nil callbacks
1670
+ [self stopSession:nil reject:nil];
1671
+ }
1672
+
1673
+ - (void)performBatchUploadIfNeeded {
1674
+ RJLogInfo(@"[RJ-BATCH] performBatchUploadIfNeeded called, isRecording=%d, "
1675
+ @"isShuttingDown=%d",
1676
+ self.isRecording, self.isShuttingDown);
1677
+
1678
+ if (!self.isRecording || self.isShuttingDown) {
1679
+ RJLogInfo(@"[RJ-BATCH] Early return - not recording or shutting down");
1680
+ return;
1681
+ }
1682
+ if (!self.uploadManager || self.uploadManager.isUploading) {
1683
+ RJLogInfo(@"[RJ-BATCH] Early return - uploadManager=%@, isUploading=%d",
1684
+ self.uploadManager ? @"exists" : @"nil",
1685
+ self.uploadManager.isUploading);
1686
+ return;
1687
+ }
1688
+
1689
+ @try {
1690
+
1691
+ __block NSArray<NSDictionary *> *events = nil;
1692
+ __block NSInteger eventCount = 0;
1693
+ [self performStateSync:^{
1694
+ events = [self.sessionEvents copy];
1695
+ eventCount = events.count;
1696
+ }];
1697
+
1698
+ if (!events || events.count == 0) {
1699
+ RJLogInfo(@"[RJ-BATCH] No events to upload");
1700
+ return;
1701
+ }
1702
+
1703
+ RJLogInfo(@"[RJ-BATCH] Uploading %ld events for session %@",
1704
+ (long)eventCount, self.currentSessionId);
1705
+
1706
+ __weak __typeof__(self) weakSelf = self;
1707
+ [self.uploadManager
1708
+ uploadBatchWithEvents:events
1709
+ isFinal:NO
1710
+ completion:^(BOOL success) {
1711
+ RJLogInfo(@"[RJ-BATCH] Batch upload completed, success=%d",
1712
+ success);
1713
+ __strong __typeof__(weakSelf) strongSelf = weakSelf;
1714
+ if (!strongSelf || strongSelf.isShuttingDown)
1715
+ return;
1716
+
1717
+ if (success) {
1718
+ if (eventCount > 0) {
1719
+
1720
+ __typeof__(strongSelf) innerSelf = strongSelf;
1721
+
1722
+ dispatch_async(strongSelf.stateQueue, ^{
1723
+ @try {
1724
+ if (innerSelf.sessionEvents.count >= eventCount) {
1725
+ [innerSelf.sessionEvents
1726
+ removeObjectsInRange:NSMakeRange(
1727
+ 0, eventCount)];
1728
+ }
1729
+ } @catch (NSException *innerException) {
1730
+ RJLogWarning(@"Failed to remove events: %@",
1731
+ innerException);
1732
+ }
1733
+ });
1734
+ }
1735
+ }
1736
+ }];
1737
+ } @catch (NSException *outerException) {
1738
+ RJLogWarning(@"Batch upload preparation failed: %@", outerException);
1739
+ }
1740
+ }
1741
+
1742
+ - (void)scheduleImmediateUploadKick {
1743
+ if (!self.isRecording || self.isShuttingDown)
1744
+ return;
1745
+
1746
+ NSTimeInterval nowMs = [[NSDate date] timeIntervalSince1970] * 1000.0;
1747
+ if (nowMs - self.lastImmediateUploadKickMs < 1000.0)
1748
+ return;
1749
+ self.lastImmediateUploadKickMs = nowMs;
1750
+
1751
+ dispatch_async(dispatch_get_main_queue(), ^{
1752
+ [self performBatchUploadIfNeeded];
1753
+ });
1754
+ }
1755
+
1756
+ /// Flush data with option to end session or keep it alive for resumption.
1757
+ /// @param isFinal If YES, ends the session on backend. If NO, just uploads
1758
+ /// pending data.
1759
+ - (void)flushDataWithCompletion:(RJCompletionHandler)completion
1760
+ isFinal:(BOOL)isFinal {
1761
+
1762
+ RJCompletionHandler safeCompletion = [completion copy];
1763
+
1764
+ @try {
1765
+
1766
+ __weak __typeof__(self) weakSelf = self;
1767
+
1768
+ dispatch_after(
1769
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)),
1770
+ dispatch_get_main_queue(), ^{
1771
+ __strong __typeof__(weakSelf) strongSelf = weakSelf;
1772
+
1773
+ if (!strongSelf) {
1774
+ if (safeCompletion)
1775
+ safeCompletion(NO);
1776
+ return;
1777
+ }
1778
+
1779
+ @try {
1780
+
1781
+ __typeof__(strongSelf) innerSelf = strongSelf;
1782
+
1783
+ __block NSArray<NSDictionary *> *events = nil;
1784
+ [strongSelf performStateSync:^{
1785
+ events = [innerSelf.sessionEvents copy];
1786
+ }];
1787
+
1788
+ NSInteger crashCount = 0;
1789
+ NSInteger anrCount = 0;
1790
+ NSInteger errorCount = 0;
1791
+ for (NSDictionary *event in events) {
1792
+ NSString *type = event[@"type"];
1793
+ if ([type isEqualToString:@"crash"])
1794
+ crashCount++;
1795
+ else if ([type isEqualToString:@"anr"])
1796
+ anrCount++;
1797
+ else if ([type isEqualToString:@"error"])
1798
+ errorCount++;
1799
+ }
1800
+
1801
+ NSDictionary *metrics = @{
1802
+ @"crashCount" : @(crashCount),
1803
+ @"anrCount" : @(anrCount),
1804
+ @"errorCount" : @(errorCount),
1805
+ @"durationSeconds" :
1806
+ @((NSInteger)([[NSDate date] timeIntervalSince1970] -
1807
+ strongSelf.sessionStartTime))
1808
+ };
1809
+
1810
+ if (!strongSelf.uploadManager) {
1811
+ if (safeCompletion)
1812
+ safeCompletion(NO);
1813
+ return;
1814
+ }
1815
+
1816
+ // For non-final flushes, skip promotion evaluation and just upload
1817
+ if (!isFinal) {
1818
+ RJLogInfo(
1819
+ @"[RJ-FLUSH] Non-final background flush - uploading %lu "
1820
+ @"events without ending session",
1821
+ (unsigned long)(events ?: @[]).count);
1822
+ [strongSelf.uploadManager uploadBatchWithEvents:events ?: @[]
1823
+ isFinal:NO
1824
+ completion:safeCompletion];
1825
+ return;
1826
+ }
1827
+
1828
+ [strongSelf.uploadManager
1829
+ evaluateReplayPromotionWithMetrics:metrics
1830
+ completion:^(BOOL promoted,
1831
+ NSString *reason) {
1832
+ __strong __typeof__(weakSelf)
1833
+ innerStrongSelf = weakSelf;
1834
+ if (!innerStrongSelf) {
1835
+ if (safeCompletion)
1836
+ safeCompletion(NO);
1837
+ return;
1838
+ }
1839
+
1840
+ if (!promoted) {
1841
+ RJLogDebug(
1842
+ @"Session not promoted for "
1843
+ @"replay (reason: %@)",
1844
+ reason);
1845
+ } else {
1846
+ RJLogDebug(
1847
+ @"Session promoted (reason: "
1848
+ @"%@)",
1849
+ reason);
1850
+ }
1851
+
1852
+ if (innerStrongSelf.uploadManager
1853
+ .isUploading) {
1854
+ RJLogDebug(
1855
+ @"Upload in progress during "
1856
+ @"flush; waiting then "
1857
+ @"performing synchronous final "
1858
+ @"upload");
1859
+ dispatch_async(
1860
+ dispatch_get_global_queue(
1861
+ DISPATCH_QUEUE_PRIORITY_DEFAULT,
1862
+ 0),
1863
+ ^{
1864
+ BOOL ok = [innerStrongSelf
1865
+ .uploadManager
1866
+ synchronousUploadWithEvents:
1867
+ events ?: @[]];
1868
+ dispatch_async(
1869
+ dispatch_get_main_queue(),
1870
+ ^{
1871
+ if (safeCompletion)
1872
+ safeCompletion(ok);
1873
+ });
1874
+ });
1875
+ } else {
1876
+ [innerStrongSelf.uploadManager
1877
+ uploadBatchWithEvents:events
1878
+ ?: @[]
1879
+ isFinal:YES
1880
+ completion:
1881
+ safeCompletion];
1882
+ }
1883
+ }];
1884
+ } @catch (NSException *exception) {
1885
+ RJLogWarning(@"Flush data failed: %@", exception);
1886
+ if (safeCompletion)
1887
+ safeCompletion(NO);
1888
+ }
1889
+ });
1890
+ } @catch (NSException *exception) {
1891
+ RJLogWarning(@"Flush preparation failed: %@", exception);
1892
+ if (safeCompletion)
1893
+ safeCompletion(NO);
1894
+ }
1895
+ }
1896
+
1897
+ /// Convenience method for final flush (ends session).
1898
+ - (void)flushAllDataWithCompletion:(RJCompletionHandler)completion {
1899
+ [self flushDataWithCompletion:completion isFinal:YES];
1900
+ }
1901
+
1902
+ /// Flush pending data for background transition without ending the session.
1903
+ /// Used when app goes to background but may return quickly.
1904
+ - (void)flushDataForBackgroundWithCompletion:(RJCompletionHandler)completion {
1905
+ [self flushDataWithCompletion:completion isFinal:NO];
1906
+ }
1907
+
1908
+ #pragma mark - Helpers
1909
+
1910
+ - (RJCaptureImportance)importanceFromString:(NSString *)string {
1911
+ if ([string isEqualToString:@"low"]) {
1912
+ return RJCaptureImportanceLow;
1913
+ } else if ([string isEqualToString:@"high"]) {
1914
+ return RJCaptureImportanceHigh;
1915
+ } else if ([string isEqualToString:@"critical"]) {
1916
+ return RJCaptureImportanceCritical;
1917
+ }
1918
+ return RJCaptureImportanceMedium;
1919
+ }
1920
+
1921
+ #pragma mark - Notifications
1922
+
1923
+ - (void)registerNotifications {
1924
+
1925
+ RJLogInfo(@"[RJ-LIFECYCLE] registerNotifications called (isRecording=%@, "
1926
+ @"lifecycleManager=%@)",
1927
+ self.isRecording ? @"YES" : @"NO",
1928
+ self.lifecycleManager ? @"exists" : @"nil");
1929
+ self.lifecycleManager.isRecording = self.isRecording;
1930
+ [self.lifecycleManager startObserving];
1931
+ }
1932
+
1933
+ #pragma mark - RJLifecycleManagerDelegate
1934
+
1935
+ - (void)lifecycleManagerKeyboardDidShow:(CGRect)keyboardFrame {
1936
+ RJLogInfo(
1937
+ @"[RJ-DELEGATE] lifecycleManagerKeyboardDidShow called, isRecording=%d",
1938
+ self.isRecording);
1939
+ if (self.isRecording) {
1940
+ RJLogDebug(@"[KEYBOARD] Keyboard shown (height=%.0f)",
1941
+ keyboardFrame.size.height);
1942
+ // Include keyboard height so web UI can render overlay
1943
+ [self logEventInternal:RJEventTypeKeyboardShow
1944
+ details:@{
1945
+ @"keyboardHeight" : @(keyboardFrame.size.height),
1946
+ @"keyboardY" : @(keyboardFrame.origin.y)
1947
+ }];
1948
+ }
1949
+ }
1950
+
1951
+ - (void)lifecycleManagerKeyboardWillHide:(NSInteger)keyPressCount {
1952
+ RJLogInfo(
1953
+ @"[RJ-DELEGATE] lifecycleManagerKeyboardWillHide called, isRecording=%d",
1954
+ self.isRecording);
1955
+ if (self.isRecording) {
1956
+ RJLogDebug(@"[KEYBOARD] Keyboard hiding (keyPresses=%ld)",
1957
+ (long)keyPressCount);
1958
+ if (keyPressCount > 0) {
1959
+ [self logEventInternal:RJEventTypeKeyboardTyping
1960
+ details:@{@"keyPressCount" : @(keyPressCount)}];
1961
+ }
1962
+ [self logEventInternal:RJEventTypeKeyboardHide details:nil];
1963
+ }
1964
+ }
1965
+
1966
+ - (void)lifecycleManagerTextDidChange {
1967
+ if (!self.isRecording || self.isShuttingDown)
1968
+ return;
1969
+
1970
+ NSTimeInterval nowMs = [[NSDate date] timeIntervalSince1970] * 1000;
1971
+ if (self.lastKeyboardTypingEventTimeMs > 0 &&
1972
+ (nowMs - self.lastKeyboardTypingEventTimeMs) < 250) {
1973
+ return;
1974
+ }
1975
+ self.lastKeyboardTypingEventTimeMs = nowMs;
1976
+
1977
+ [self logEventInternal:RJEventTypeKeyboardTyping
1978
+ details:@{@"keyPressCount" : @1}];
1979
+ }
1980
+
1981
+ - (void)lifecycleManagerDidResignActive {
1982
+ RJLogInfo(@"[RJ-DELEGATE] lifecycleManagerDidResignActive called");
1983
+ }
1984
+
1985
+ - (void)lifecycleManagerDidEnterBackground {
1986
+ RJLogInfo(@"[RJ-DELEGATE] lifecycleManagerDidEnterBackground called, "
1987
+ @"isRecording=%d, isShuttingDown=%d",
1988
+ self.isRecording, self.isShuttingDown);
1989
+
1990
+ if (!self.isRecording || self.isShuttingDown) {
1991
+ RJLogInfo(
1992
+ @"[RJ-DELEGATE] lifecycleManagerDidEnterBackground - early return "
1993
+ @"(not recording or shutting down)");
1994
+ return;
1995
+ }
1996
+
1997
+ RJLogInfo(@"[RJ-DELEGATE] App entered background - flushing all data");
1998
+
1999
+ @try {
2000
+ [self stopBatchUploadTimer];
2001
+
2002
+ // CRITICAL: Sync accumulated background time to uploadManager BEFORE any
2003
+ // flush This ensures if the session ends during background, the correct
2004
+ // time is included
2005
+ if (self.uploadManager && self.lifecycleManager) {
2006
+ NSTimeInterval currentBgTime =
2007
+ self.lifecycleManager.totalBackgroundTimeMs;
2008
+ self.uploadManager.totalBackgroundTimeMs = currentBgTime;
2009
+ RJLogInfo(
2010
+ @"[RJ-DELEGATE] Synced background time to uploadManager: %.0fms",
2011
+ currentBgTime);
2012
+ }
2013
+
2014
+ // Log background event FIRST and SYNCHRONOUSLY so it's included in the
2015
+ // flush This must happen before we pause capture or start the flush
2016
+ NSMutableDictionary *bgEvent =
2017
+ [NSMutableDictionary dictionaryWithCapacity:3];
2018
+ bgEvent[@"type"] = RJEventTypeAppBackground;
2019
+ bgEvent[@"timestamp"] = @([RJWindowUtils currentTimestampMillis]);
2020
+
2021
+ // Add directly to session events synchronously
2022
+ [self performStateSync:^{
2023
+ if (self.sessionEvents) {
2024
+ [self.sessionEvents addObject:bgEvent];
2025
+ RJLogInfo(@"[RJ-EVENT] Added app_background event synchronously (total "
2026
+ @"events: %lu)",
2027
+ (unsigned long)self.sessionEvents.count);
2028
+ }
2029
+ }];
2030
+
2031
+ // Also add to event buffer for persistence
2032
+ if (self.eventBuffer) {
2033
+ [self.eventBuffer appendEvent:bgEvent];
2034
+ }
2035
+
2036
+ if (self.captureEngine) {
2037
+ @try {
2038
+ // Use ASYNCHRONOUS pause for backgrounding.
2039
+ // The encoder now implements internal UIBackgroundTask protection, so
2040
+ // it will finish finalizing the MP4 on a background queue without
2041
+ // locking the UI.
2042
+ RJLogInfo(@"[RJ-VIDEO] Pausing video capture for background (ASYNC)");
2043
+ [self.captureEngine pauseVideoCapture];
2044
+ RJLogInfo(@"[RJ-VIDEO] Video capture pause initiated");
2045
+ } @catch (NSException *e) {
2046
+ RJLogWarning(@"Background capture failed: %@", e);
2047
+ }
2048
+ }
2049
+
2050
+ __weak __typeof__(self) weakSelf = self;
2051
+ if (self.uploadManager) {
2052
+ self.backgroundTaskId = [self.uploadManager
2053
+ beginBackgroundTaskWithName:@"RejourneySessionFlush"];
2054
+ }
2055
+
2056
+ // Use non-final flush for background - session may resume if user returns
2057
+ // quickly This avoids calling session/end which would prevent frame capture
2058
+ // after returning
2059
+ RJLogInfo(@"[RJ-FLUSH] Starting non-final background flush (session will "
2060
+ @"resume if user returns)");
2061
+ [self flushDataForBackgroundWithCompletion:^(BOOL success) {
2062
+ __strong __typeof__(weakSelf) strongSelf = weakSelf;
2063
+ if (!strongSelf)
2064
+ return;
2065
+
2066
+ __typeof__(strongSelf) innerSelf = strongSelf;
2067
+ dispatch_async(dispatch_get_main_queue(), ^{
2068
+ @try {
2069
+ if (success) {
2070
+ RJLogDebug(@"Background flush successful");
2071
+
2072
+ [innerSelf.uploadManager updateSessionRecoveryMeta];
2073
+ } else {
2074
+ RJLogWarning(@"Background flush failed - data may be lost");
2075
+ }
2076
+
2077
+ if (innerSelf.uploadManager &&
2078
+ innerSelf.backgroundTaskId != UIBackgroundTaskInvalid) {
2079
+ [innerSelf.uploadManager
2080
+ endBackgroundTask:innerSelf.backgroundTaskId];
2081
+ innerSelf.backgroundTaskId = UIBackgroundTaskInvalid;
2082
+ }
2083
+ } @catch (NSException *e) {
2084
+ RJLogWarning(@"Background task cleanup failed: %@", e);
2085
+ }
2086
+ });
2087
+ }];
2088
+ } @catch (NSException *exception) {
2089
+ RJLogError(@"App background handling failed: %@", exception);
2090
+ }
2091
+ }
2092
+
2093
+ - (void)lifecycleManagerWillTerminate {
2094
+ if (!self.isRecording)
2095
+ return;
2096
+
2097
+ RJLogDebug(
2098
+ @"[LIFECYCLE] App TERMINATING - attempting final flush (sessionId=%@)",
2099
+ self.currentSessionId);
2100
+ self.isRecording = NO;
2101
+ if (self.lifecycleManager) {
2102
+ self.lifecycleManager.isRecording = NO;
2103
+ }
2104
+ self.isShuttingDown = YES;
2105
+
2106
+ RJLogDebug(@"App terminating - synchronous flush");
2107
+
2108
+ @try {
2109
+ [self stopBatchUploadTimer];
2110
+
2111
+ // CRITICAL: Calculate and sync background time before ending session
2112
+ // If we're being terminated while in background, we need to include
2113
+ // the current background duration in the session's total background time
2114
+ if (self.uploadManager && self.lifecycleManager) {
2115
+ NSTimeInterval totalBgTime = self.lifecycleManager.totalBackgroundTimeMs;
2116
+
2117
+ // If we're currently in background, add the ongoing duration
2118
+ if (self.lifecycleManager.isInBackground &&
2119
+ self.lifecycleManager.backgroundEntryTime > 0) {
2120
+ NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
2121
+ NSTimeInterval currentBgDurationMs =
2122
+ (currentTime - self.lifecycleManager.backgroundEntryTime) * 1000;
2123
+ totalBgTime += currentBgDurationMs;
2124
+ RJLogInfo(@"[RJ-TERMINATE] Adding current background duration: %.0fms, "
2125
+ @"total: %.0fms",
2126
+ currentBgDurationMs, totalBgTime);
2127
+ }
2128
+
2129
+ self.uploadManager.totalBackgroundTimeMs = totalBgTime;
2130
+ RJLogInfo(
2131
+ @"[RJ-TERMINATE] Synced total background time to uploadManager: "
2132
+ @"%.0fms",
2133
+ totalBgTime);
2134
+ }
2135
+
2136
+ if (self.captureEngine) {
2137
+ // Use SYNCHRONOUS stop to ensure segment is finished and upload is
2138
+ // triggered before we try to upload events. This prevents video segments
2139
+ // from being lost when app is terminated.
2140
+ RJLogInfo(@"[RJ-TERMINATE] Stopping capture engine synchronously");
2141
+ [self.captureEngine stopSessionSync];
2142
+ RJLogInfo(@"[RJ-TERMINATE] Capture engine stopped");
2143
+ }
2144
+
2145
+ // Add terminated event SYNCHRONOUSLY before we copy events
2146
+ NSMutableDictionary *terminateEvent =
2147
+ [NSMutableDictionary dictionaryWithCapacity:3];
2148
+ terminateEvent[@"type"] = RJEventTypeAppTerminated;
2149
+ terminateEvent[@"timestamp"] = @([RJWindowUtils currentTimestampMillis]);
2150
+
2151
+ [self performStateSync:^{
2152
+ if (self.sessionEvents) {
2153
+ [self.sessionEvents addObject:terminateEvent];
2154
+ RJLogInfo(@"[RJ-EVENT] Added app_terminated event synchronously (total "
2155
+ @"events: %lu)",
2156
+ (unsigned long)self.sessionEvents.count);
2157
+ }
2158
+ }];
2159
+
2160
+ __block NSArray<NSDictionary *> *events = nil;
2161
+ @try {
2162
+ [self performStateSync:^{
2163
+ events = [self.sessionEvents copy];
2164
+ }];
2165
+ } @catch (NSException *e) {
2166
+ events = @[];
2167
+ }
2168
+ if (!events) {
2169
+ events = @[];
2170
+ }
2171
+
2172
+ if (self.uploadManager && events.count > 0) {
2173
+ @try {
2174
+ [self.uploadManager synchronousUploadWithEvents:events ?: @[]];
2175
+ } @catch (NSException *e) {
2176
+ RJLogWarning(@"Terminate upload failed: %@", e);
2177
+ }
2178
+ }
2179
+ } @catch (NSException *exception) {
2180
+ RJLogError(@"App termination handling failed: %@", exception);
2181
+ }
2182
+ }
2183
+
2184
+ - (void)lifecycleManagerDidBecomeActive {
2185
+ RJLogInfo(@"[RJ-DELEGATE] lifecycleManagerDidBecomeActive called, "
2186
+ @"isRecording=%d, isShuttingDown=%d",
2187
+ self.isRecording, self.isShuttingDown);
2188
+
2189
+ if (!self.isRecording || self.isShuttingDown) {
2190
+ RJLogInfo(
2191
+ @"[RJ-DELEGATE] lifecycleManagerDidBecomeActive - early return (not "
2192
+ @"recording or shutting down)");
2193
+ return;
2194
+ }
2195
+
2196
+ RJLogInfo(@"[RJ-DELEGATE] App became active - resuming video capture");
2197
+
2198
+ @try {
2199
+ NSTimeInterval bgTimeMs = self.lifecycleManager.totalBackgroundTimeMs;
2200
+
2201
+ // Keep local state in sync so stopSession/flush paths can rely on it.
2202
+ self.totalBackgroundTimeMs = bgTimeMs;
2203
+
2204
+ if (self.uploadManager) {
2205
+ self.uploadManager.totalBackgroundTimeMs = bgTimeMs;
2206
+ }
2207
+
2208
+ [self logEventInternal:RJEventTypeAppForeground
2209
+ details:@{@"totalBackgroundTime" : @(bgTimeMs)}];
2210
+
2211
+ [self startBatchUploadTimer];
2212
+
2213
+ if (self.captureEngine && self.remoteRecordingEnabled) {
2214
+ RJLogInfo(@"[RJ-VIDEO] Calling resumeVideoCapture");
2215
+ [self.captureEngine resumeVideoCapture];
2216
+ } else if (!self.remoteRecordingEnabled) {
2217
+ RJLogInfo(
2218
+ @"[RJ-VIDEO] Video capture resume skipped - recording disabled");
2219
+ } else {
2220
+ RJLogInfo(
2221
+ @"[RJ-VIDEO] captureEngine is nil, cannot resume video capture");
2222
+ }
2223
+
2224
+ NSString *scheme = nil;
2225
+ if ([self.lifecycleManager consumeExternalURLOpenedWithScheme:&scheme]) {
2226
+ [self logEventInternal:RJEventTypeOAuthReturned
2227
+ details:@{@"scheme" : scheme ?: @"unknown"}];
2228
+ }
2229
+ } @catch (NSException *exception) {
2230
+ RJLogWarning(@"App foreground handling failed: %@", exception);
2231
+ }
2232
+ }
2233
+
2234
+ - (void)lifecycleManagerSessionDidTimeout:(NSTimeInterval)backgroundDuration {
2235
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] Called with backgroundDuration=%.1fs",
2236
+ backgroundDuration);
2237
+ NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
2238
+ [self handleSessionTimeout:backgroundDuration currentTime:currentTime];
2239
+ }
2240
+
2241
+ /// Handle session timeout after extended background period.
2242
+ /// This cleanly ends the old session and starts a fresh one.
2243
+ ///
2244
+ /// Flow:
2245
+ /// 1. Capture final background time from lifecycle manager
2246
+ /// 2. Stop timers and capture engine for old session
2247
+ /// 3. Synchronously end the old session with correct background time
2248
+ /// 4. Create new session ID and reset all state
2249
+ /// 5. Start capture for new session
2250
+ /// 6. Trigger immediate upload to register new session
2251
+ - (void)handleSessionTimeout:(NSTimeInterval)backgroundDuration
2252
+ currentTime:(NSTimeInterval)currentTime {
2253
+ RJLogInfo(
2254
+ @"[RJ-SESSION-TIMEOUT] handleSessionTimeout: bg=%.1fs, isRecording=%d",
2255
+ backgroundDuration, self.isRecording);
2256
+
2257
+ @try {
2258
+ NSString *oldSessionId = self.currentSessionId ?: @"none";
2259
+ BOOL wasRecording = self.isRecording;
2260
+
2261
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] === ENDING OLD SESSION: %@ ===",
2262
+ oldSessionId);
2263
+
2264
+ // ========== STEP 1: Capture background time BEFORE any state changes
2265
+ // ==========
2266
+ NSTimeInterval totalBackgroundMs = 0;
2267
+ if (self.lifecycleManager) {
2268
+ totalBackgroundMs = self.lifecycleManager.totalBackgroundTimeMs;
2269
+ RJLogInfo(
2270
+ @"[RJ-SESSION-TIMEOUT] Total background time from lifecycle: %.0fms",
2271
+ totalBackgroundMs);
2272
+ }
2273
+
2274
+ // ========== STEP 2: Stop all capture/timers for old session ==========
2275
+ [self stopBatchUploadTimer];
2276
+ [self stopDurationLimitTimer];
2277
+
2278
+ if (wasRecording && self.captureEngine) {
2279
+ @try {
2280
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] Stopping capture engine");
2281
+ [self.captureEngine stopSession];
2282
+ } @catch (NSException *e) {
2283
+ RJLogWarning(@"Capture stop failed: %@", e);
2284
+ }
2285
+ }
2286
+
2287
+ // ========== STEP 3: End old session SYNCHRONOUSLY with correct background
2288
+ // time ==========
2289
+ if (wasRecording && self.uploadManager && oldSessionId.length > 0 &&
2290
+ ![oldSessionId isEqualToString:@"none"]) {
2291
+ // Set background time on upload manager
2292
+ self.uploadManager.totalBackgroundTimeMs = totalBackgroundMs;
2293
+
2294
+ // Get current events for final upload
2295
+ __block NSArray<NSDictionary *> *finalEvents = nil;
2296
+ [self performStateSync:^{
2297
+ finalEvents = [self.sessionEvents copy];
2298
+ }];
2299
+
2300
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] Ending old session with %lu events, "
2301
+ @"bgTime=%.0fms",
2302
+ (unsigned long)finalEvents.count, totalBackgroundMs);
2303
+
2304
+ // Synchronous upload and session end
2305
+ if (finalEvents.count > 0) {
2306
+ [self.uploadManager synchronousUploadWithEvents:finalEvents];
2307
+ } else {
2308
+ // Even with no events, end the session to record background time
2309
+ [self.uploadManager endSessionSync];
2310
+ }
2311
+
2312
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] Old session %@ ended", oldSessionId);
2313
+ }
2314
+
2315
+ // ========== STEP 4: Reset ALL state for new session ==========
2316
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] === STARTING NEW SESSION ===");
2317
+
2318
+ __block NSString *newSessionId = nil;
2319
+ [self performStateSync:^{
2320
+ newSessionId = [RJWindowUtils generateSessionId];
2321
+ self.currentSessionId = newSessionId;
2322
+ self.sessionStartTime = currentTime;
2323
+ self.totalBackgroundTimeMs = 0;
2324
+ [self.sessionEvents removeAllObjects];
2325
+
2326
+ // Persist for crash recovery
2327
+ [[NSUserDefaults standardUserDefaults]
2328
+ setObject:newSessionId
2329
+ forKey:@"rj_current_session_id"];
2330
+ [[NSUserDefaults standardUserDefaults] synchronize];
2331
+ }];
2332
+
2333
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] New session ID: %@", newSessionId);
2334
+
2335
+ // Reset upload manager for new session
2336
+ if (self.uploadManager) {
2337
+ @try {
2338
+ [self.uploadManager resetForNewSession];
2339
+ } @catch (NSException *e) {
2340
+ RJLogWarning(@"Upload manager reset failed: %@", e);
2341
+ }
2342
+
2343
+ self.uploadManager.sessionId = newSessionId;
2344
+ self.uploadManager.sessionStartTime = currentTime;
2345
+ self.uploadManager.totalBackgroundTimeMs = 0;
2346
+
2347
+ // Preserve user identity
2348
+ self.uploadManager.userId = self.userId ?: @"anonymous";
2349
+ }
2350
+
2351
+ // Reset lifecycle manager background tracking
2352
+ if (self.lifecycleManager) {
2353
+ [self.lifecycleManager resetBackgroundTime];
2354
+ self.lifecycleManager.isRecording = YES;
2355
+ }
2356
+
2357
+ // Reset event buffer for new session - recreate since sessionId is readonly
2358
+ if (self.eventBuffer) {
2359
+ [self.eventBuffer clearAllEvents];
2360
+ }
2361
+ // Create new event buffer for new session (sessionId is readonly)
2362
+ NSString *pendingPath = self.eventBuffer.pendingRootPath;
2363
+ if (pendingPath.length == 0) {
2364
+ pendingPath =
2365
+ [NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
2366
+ NSUserDomainMask, YES)
2367
+ .firstObject stringByAppendingPathComponent:@"rj_pending"];
2368
+ }
2369
+ self.eventBuffer = [[RJEventBuffer alloc] initWithSessionId:newSessionId
2370
+ pendingRootPath:pendingPath];
2371
+
2372
+ // ========== STEP 5: Start capture for new session ==========
2373
+ [self resetSamplingDecision];
2374
+ self.remoteRecordingEnabled = self.recordingEnabledByConfig;
2375
+ if (self.captureEngine) {
2376
+ self.captureEngine.uploadsEnabled = self.remoteRecordingEnabled;
2377
+ }
2378
+ if (self.hasProjectConfig) {
2379
+ [self updateRecordingEligibilityWithSampleRate:self.sampleRate];
2380
+ }
2381
+
2382
+ if (self.captureEngine && self.remoteRecordingEnabled) {
2383
+ @try {
2384
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] Starting capture engine for %@",
2385
+ newSessionId);
2386
+ [self.captureEngine startSessionWithId:newSessionId];
2387
+ } @catch (NSException *e) {
2388
+ RJLogWarning(@"New session capture start failed: %@", e);
2389
+ }
2390
+ } else {
2391
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] Capture skipped - recording disabled");
2392
+ }
2393
+
2394
+ self.isRecording = YES;
2395
+
2396
+ // Verify touch tracking
2397
+ RJTouchInterceptor *touchInterceptor = [RJTouchInterceptor sharedInstance];
2398
+ if (touchInterceptor && !touchInterceptor.isTrackingEnabled) {
2399
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] Re-enabling touch tracking");
2400
+ [self setupTouchTracking];
2401
+ }
2402
+
2403
+ // Start timers for new session
2404
+ [self startBatchUploadTimer];
2405
+ [self startDurationLimitTimer];
2406
+
2407
+ // ========== STEP 6: Log session_start and trigger immediate upload
2408
+ // ==========
2409
+ NSMutableDictionary *sessionStartEvent = [NSMutableDictionary dictionary];
2410
+ sessionStartEvent[@"type"] = RJEventTypeSessionStart;
2411
+ sessionStartEvent[@"timestamp"] = @([RJWindowUtils currentTimestampMillis]);
2412
+ sessionStartEvent[@"previousSessionId"] = oldSessionId;
2413
+ sessionStartEvent[@"backgroundDuration"] = @(backgroundDuration * 1000);
2414
+ sessionStartEvent[@"reason"] = @"background_timeout";
2415
+ sessionStartEvent[@"userId"] = self.userId ?: @"anonymous";
2416
+
2417
+ [self performStateSync:^{
2418
+ [self.sessionEvents addObject:sessionStartEvent];
2419
+ }];
2420
+
2421
+ if (self.eventBuffer) {
2422
+ [self.eventBuffer appendEvent:sessionStartEvent];
2423
+ }
2424
+
2425
+ // Immediate upload to register session with backend
2426
+ dispatch_after(
2427
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)),
2428
+ dispatch_get_main_queue(), ^{
2429
+ [self performBatchUploadIfNeeded];
2430
+ });
2431
+
2432
+ RJLogInfo(@"[RJ-SESSION-TIMEOUT] Session restart complete: %@ -> %@",
2433
+ oldSessionId, newSessionId);
2434
+
2435
+ } @catch (NSException *exception) {
2436
+ RJLogError(@"Session timeout handling failed: %@", exception);
2437
+ }
2438
+ }
2439
+
2440
+ #pragma mark - RJViewControllerTrackerDelegate
2441
+
2442
+ - (void)viewControllerDidAppear:(UIViewController *)viewController
2443
+ screenName:(NSString *)screenName {
2444
+ if (!self.isRecording)
2445
+ return;
2446
+
2447
+ @try {
2448
+ RJLogDebug(@"Auto-detected navigation to: %@", screenName);
2449
+
2450
+ [self logEventInternal:RJEventTypeNavigation
2451
+ details:@{@"screen" : screenName, @"auto" : @YES}];
2452
+
2453
+ [self.captureEngine notifyNavigationToScreen:screenName];
2454
+ [self.captureEngine notifyReactNativeCommit];
2455
+ } @catch (NSException *exception) {
2456
+ RJLogWarning(@"Navigation tracking failed: %@", exception);
2457
+ }
2458
+ }
2459
+
2460
+ - (void)viewControllerWillDisappear:(UIViewController *)viewController
2461
+ screenName:(NSString *)screenName {
2462
+ }
2463
+
2464
+ - (void)tabBarDidSelectIndex:(NSInteger)index
2465
+ fromIndex:(NSInteger)previousIndex {
2466
+ if (!self.isRecording)
2467
+ return;
2468
+
2469
+ @try {
2470
+ RJLogDebug(@"Auto-detected tab change: %ld -> %ld", (long)previousIndex,
2471
+ (long)index);
2472
+
2473
+ [self logEventInternal:RJEventTypeNavigation
2474
+ details:@{
2475
+ @"type" : @"tab_change",
2476
+ @"fromIndex" : @(previousIndex),
2477
+ @"toIndex" : @(index),
2478
+ @"auto" : @YES
2479
+ }];
2480
+
2481
+ } @catch (NSException *exception) {
2482
+ RJLogWarning(@"Tab change tracking failed: %@", exception);
2483
+ }
2484
+ }
2485
+
2486
+ #pragma mark - TurboModule Support
2487
+
2488
+ #ifdef RCT_NEW_ARCH_ENABLED
2489
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
2490
+ (const facebook::react::ObjCTurboModule::InitParams &)params {
2491
+ return std::make_shared<facebook::react::NativeRejourneySpecJSI>(params);
2492
+ }
2493
+ #endif
2494
+
2495
+ @end