@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,2296 @@
1
+ //
2
+ // RJUploadManager.m
3
+ // Rejourney
4
+ //
5
+ // Session data upload management implementation.
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 "RJUploadManager.h"
23
+ #import "../Core/RJConstants.h"
24
+ #import "../Core/RJLogger.h"
25
+ #import "../Utils/RJGzipUtils.h"
26
+ #import "../Utils/RJKeychainManager.h"
27
+ #import "../Utils/RJTelemetry.h"
28
+ #import "RJDeviceAuthManager.h"
29
+ #import "RJNetworkMonitor.h"
30
+ #import "RJRetryManager.h"
31
+ #import <UIKit/UIKit.h>
32
+ #import <sys/utsname.h>
33
+
34
+ static NSString *RJRedactedURLForLogFromURL(NSURL *url) {
35
+ if (!url)
36
+ return @"<nil>";
37
+
38
+ NSURLComponents *components = [NSURLComponents componentsWithURL:url
39
+ resolvingAgainstBaseURL:NO];
40
+
41
+ components.query = nil;
42
+ components.fragment = nil;
43
+
44
+ NSString *scheme = components.scheme ?: url.scheme;
45
+ NSString *host = components.host ?: url.host;
46
+ NSString *path = components.path ?: url.path;
47
+ if (scheme.length > 0 && host.length > 0) {
48
+ return [NSString stringWithFormat:@"%@://%@%@", scheme, host, path ?: @""];
49
+ }
50
+ if (host.length > 0) {
51
+ return [NSString stringWithFormat:@"%@%@", host, path ?: @""];
52
+ }
53
+
54
+ return (components.URL.absoluteString.length > 0)
55
+ ? components.URL.absoluteString
56
+ : (url.absoluteString ?: @"<invalid url>");
57
+ }
58
+
59
+ static NSString *RJRedactedURLForLogFromString(NSString *urlString) {
60
+ if (urlString.length == 0)
61
+ return @"<empty>";
62
+ NSURL *url = [NSURL URLWithString:urlString];
63
+ return RJRedactedURLForLogFromURL(url);
64
+ }
65
+
66
+ #pragma mark - Private Interface
67
+
68
+ @interface RJUploadManager ()
69
+
70
+ @property(nonatomic, strong) dispatch_queue_t uploadQueue;
71
+
72
+ @property(nonatomic, strong, nullable) NSTimer *batchUploadTimer;
73
+
74
+ @property(nonatomic, assign) NSTimeInterval lastUploadTime;
75
+
76
+ @property(nonatomic, assign) NSInteger batchNumber;
77
+
78
+ @property(nonatomic, assign) NSInteger eventBatchNumber;
79
+
80
+ @property(nonatomic, assign) BOOL isUploading;
81
+
82
+ @property(nonatomic, assign) BOOL isShuttingDown;
83
+
84
+ @property(nonatomic, strong) RJDeviceAuthManager *deviceAuthManager;
85
+
86
+ @property(nonatomic, strong, nullable) NSURLSessionDataTask *activeTask;
87
+
88
+ @property(nonatomic, strong) NSString *pendingRootPath;
89
+
90
+ #pragma mark - Retry & Resilience Properties
91
+
92
+ @property(nonatomic, assign) NSInteger consecutiveFailureCount;
93
+
94
+ @property(nonatomic, assign) BOOL isCircuitOpen;
95
+
96
+ @property(nonatomic, assign) NSTimeInterval circuitOpenedTime;
97
+
98
+ @property(nonatomic, strong) NSMutableArray<NSDictionary *> *retryQueue;
99
+
100
+ @property(nonatomic, assign) BOOL isRetryScheduled;
101
+
102
+ #pragma mark - Replay Promotion Properties
103
+
104
+ @property(nonatomic, assign) BOOL isReplayPromoted;
105
+
106
+ @end
107
+
108
+ #pragma mark - Implementation
109
+
110
+ @implementation RJUploadManager
111
+
112
+ #pragma mark - Initialization
113
+
114
+ - (instancetype)initWithApiUrl:(NSString *)apiUrl {
115
+ self = [super init];
116
+ if (self) {
117
+ _apiUrl = [apiUrl copy] ?: @"https://api.rejourney.co";
118
+ _uploadQueue =
119
+ dispatch_queue_create("com.rejourney.upload", DISPATCH_QUEUE_SERIAL);
120
+ _batchNumber = 0;
121
+ _eventBatchNumber = 0;
122
+ _isUploading = NO;
123
+ _isShuttingDown = NO;
124
+ _lastUploadTime = 0;
125
+ _lastUploadTime = 0;
126
+ _activeTask = nil;
127
+ _deviceAuthManager = [RJDeviceAuthManager sharedManager];
128
+ _maxRecordingMinutes = 10;
129
+ _sampleRate = 100;
130
+
131
+ _consecutiveFailureCount = 0;
132
+ _isCircuitOpen = NO;
133
+ _circuitOpenedTime = 0;
134
+ _retryQueue = [NSMutableArray new];
135
+ _isRetryScheduled = NO;
136
+
137
+ NSArray<NSString *> *paths = NSSearchPathForDirectoriesInDomains(
138
+ NSCachesDirectory, NSUserDomainMask, YES);
139
+ NSString *caches = paths.firstObject ?: NSTemporaryDirectory();
140
+ _pendingRootPath = [[caches stringByAppendingPathComponent:@"rejourney"]
141
+ stringByAppendingPathComponent:@"pending_uploads"];
142
+ [[NSFileManager defaultManager] createDirectoryAtPath:_pendingRootPath
143
+ withIntermediateDirectories:YES
144
+ attributes:nil
145
+ error:nil];
146
+ }
147
+ return self;
148
+ }
149
+
150
+ #pragma mark - Crash-Safe Persistence Helpers
151
+
152
+ - (NSString *)pendingSessionDir:(NSString *)sessionId {
153
+ NSString *sid = (sessionId.length > 0) ? sessionId : @"unknown";
154
+ NSString *dir = [self.pendingRootPath stringByAppendingPathComponent:sid];
155
+ [[NSFileManager defaultManager] createDirectoryAtPath:dir
156
+ withIntermediateDirectories:YES
157
+ attributes:nil
158
+ error:nil];
159
+ return dir;
160
+ }
161
+
162
+ - (void)markSessionActiveForRecovery {
163
+ if (self.sessionId.length == 0)
164
+ return;
165
+ NSString *dir = [self pendingSessionDir:self.sessionId];
166
+ NSString *metaPath = [dir stringByAppendingPathComponent:@"session.json"];
167
+ NSDictionary *meta = @{
168
+ @"sessionId" : self.sessionId ?: @"",
169
+ @"sessionStartTime" : @(self.sessionStartTime),
170
+ @"totalBackgroundTimeMs" : @(self.totalBackgroundTimeMs),
171
+ @"updatedAt" : @([[NSDate date] timeIntervalSince1970] * 1000)
172
+ };
173
+ NSData *data = [NSJSONSerialization dataWithJSONObject:meta
174
+ options:0
175
+ error:nil];
176
+ [data writeToFile:metaPath atomically:YES];
177
+ }
178
+
179
+ - (void)updateSessionRecoveryMeta {
180
+ if (self.sessionId.length == 0)
181
+ return;
182
+ NSString *dir = [self pendingSessionDir:self.sessionId];
183
+ NSString *metaPath = [dir stringByAppendingPathComponent:@"session.json"];
184
+ NSData *existing = [NSData dataWithContentsOfFile:metaPath];
185
+ NSMutableDictionary *meta = [NSMutableDictionary new];
186
+ if (existing) {
187
+ NSDictionary *json = [NSJSONSerialization JSONObjectWithData:existing
188
+ options:0
189
+ error:nil];
190
+ if ([json isKindOfClass:[NSDictionary class]]) {
191
+ [meta addEntriesFromDictionary:json];
192
+ }
193
+ }
194
+ meta[@"sessionId"] = self.sessionId ?: @"";
195
+ meta[@"sessionStartTime"] = @(self.sessionStartTime);
196
+ meta[@"totalBackgroundTimeMs"] = @(self.totalBackgroundTimeMs);
197
+ meta[@"updatedAt"] = @([[NSDate date] timeIntervalSince1970] * 1000);
198
+ NSData *data = [NSJSONSerialization dataWithJSONObject:meta
199
+ options:0
200
+ error:nil];
201
+ [data writeToFile:metaPath atomically:YES];
202
+ }
203
+
204
+ - (void)clearSessionRecovery:(NSString *)sessionId {
205
+ if (sessionId.length == 0)
206
+ return;
207
+ NSString *dir =
208
+ [self.pendingRootPath stringByAppendingPathComponent:sessionId];
209
+ [[NSFileManager defaultManager] removeItemAtPath:dir error:nil];
210
+ }
211
+
212
+ - (NSString *)pendingFilenameForContentType:(NSString *)contentType
213
+ batchNumber:(NSInteger)batchNumber
214
+ keyframe:(BOOL)isKeyframe {
215
+ NSString *flag = isKeyframe ? @"k" : @"n";
216
+ return [NSString
217
+ stringWithFormat:@"%@_%ld_%@.gz", contentType, (long)batchNumber, flag];
218
+ }
219
+
220
+ - (void)persistPendingUploadWithContentType:(NSString *)contentType
221
+ batchNumber:(NSInteger)batchNumber
222
+ keyframe:(BOOL)isKeyframe
223
+ gzipped:(NSData *)gzipped
224
+ eventCount:(NSInteger)eventCount
225
+ frameCount:(NSInteger)frameCount {
226
+ NSString *dir = [self pendingSessionDir:self.sessionId];
227
+ NSString *name = [self pendingFilenameForContentType:contentType
228
+ batchNumber:batchNumber
229
+ keyframe:isKeyframe];
230
+ NSString *path = [dir stringByAppendingPathComponent:name];
231
+ [gzipped writeToFile:path atomically:YES];
232
+
233
+ NSDictionary *meta = @{
234
+ @"contentType" : contentType ?: @"",
235
+ @"batchNumber" : @(batchNumber),
236
+ @"isKeyframe" : @(isKeyframe),
237
+ @"eventCount" : @(eventCount),
238
+ @"frameCount" : @(frameCount),
239
+ @"createdAt" : @([[NSDate date] timeIntervalSince1970] * 1000)
240
+ };
241
+ NSData *metaData = [NSJSONSerialization dataWithJSONObject:meta
242
+ options:0
243
+ error:nil];
244
+ [metaData writeToFile:[path stringByAppendingString:@".meta.json"]
245
+ atomically:YES];
246
+ }
247
+
248
+ - (NSDictionary *)parsePendingFilename:(NSString *)name {
249
+ if (![name hasSuffix:@".gz"])
250
+ return nil;
251
+ NSString *base = [name stringByReplacingOccurrencesOfString:@".gz"
252
+ withString:@""];
253
+ NSArray<NSString *> *parts = [base componentsSeparatedByString:@"_"];
254
+ if (parts.count < 3)
255
+ return nil;
256
+ NSString *contentType = parts[0];
257
+ NSInteger batch = [parts[1] integerValue];
258
+ BOOL keyframe = [parts[2] isEqualToString:@"k"];
259
+ return @{
260
+ @"contentType" : contentType ?: @"",
261
+ @"batchNumber" : @(batch),
262
+ @"isKeyframe" : @(keyframe)
263
+ };
264
+ }
265
+
266
+ - (BOOL)flushPendingUploadsForSessionSync:(NSString *)sessionId {
267
+ if (sessionId.length == 0)
268
+ return YES;
269
+
270
+ NSString *dir =
271
+ [self.pendingRootPath stringByAppendingPathComponent:sessionId];
272
+ BOOL isDir = NO;
273
+ if (![[NSFileManager defaultManager] fileExistsAtPath:dir
274
+ isDirectory:&isDir] ||
275
+ !isDir) {
276
+ return YES;
277
+ }
278
+
279
+ NSArray<NSString *> *files =
280
+ [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dir error:nil]
281
+ ?: @[];
282
+ NSArray<NSString *> *gzFiles =
283
+ [[files filteredArrayUsingPredicate:
284
+ [NSPredicate predicateWithFormat:@"SELF ENDSWITH '.gz'"]]
285
+ sortedArrayUsingSelector:@selector(compare:)];
286
+
287
+ if (gzFiles.count == 0)
288
+ return YES;
289
+
290
+ for (NSString *name in gzFiles) {
291
+ NSDictionary *parsed = [self parsePendingFilename:name];
292
+ if (!parsed)
293
+ continue;
294
+
295
+ NSString *path = [dir stringByAppendingPathComponent:name];
296
+ NSData *gz = [NSData dataWithContentsOfFile:path];
297
+ if (!gz) {
298
+ return NO;
299
+ }
300
+
301
+ NSString *metaFile = [path stringByAppendingString:@".meta.json"];
302
+ NSData *m = [NSData dataWithContentsOfFile:metaFile];
303
+ NSDictionary *meta = m ? [NSJSONSerialization JSONObjectWithData:m
304
+ options:0
305
+ error:nil]
306
+ : @{};
307
+ NSInteger eventCount = [meta[@"eventCount"] integerValue];
308
+ NSInteger frameCount = [meta[@"frameCount"] integerValue];
309
+ BOOL isKeyframe = meta[@"isKeyframe"] ? [meta[@"isKeyframe"] boolValue]
310
+ : [parsed[@"isKeyframe"] boolValue];
311
+
312
+ NSString *contentType = parsed[@"contentType"];
313
+ NSInteger batchNumber = [parsed[@"batchNumber"] integerValue];
314
+
315
+ NSDictionary *presign = nil;
316
+ BOOL presignOk = [self presignForContentType:contentType
317
+ batchNumber:batchNumber
318
+ sizeBytes:gz.length
319
+ isKeyframe:isKeyframe
320
+ result:&presign];
321
+ if (!presignOk || ![presign isKindOfClass:[NSDictionary class]]) {
322
+ return NO;
323
+ }
324
+
325
+ NSString *uploadUrl = presign[@"presignedUrl"];
326
+ NSString *batchId = presign[@"batchId"];
327
+ if (![self uploadData:gz
328
+ toPresignedURL:uploadUrl
329
+ contentType:@"application/gzip"]) {
330
+ return NO;
331
+ }
332
+
333
+ BOOL completeOk = [self completeBatchWithId:batchId
334
+ actualSizeBytes:gz.length
335
+ eventCount:eventCount
336
+ frameCount:frameCount];
337
+ if (completeOk) {
338
+ [[NSFileManager defaultManager] removeItemAtPath:path error:nil];
339
+ [[NSFileManager defaultManager] removeItemAtPath:metaFile error:nil];
340
+ } else {
341
+ return NO;
342
+ }
343
+ }
344
+
345
+ return YES;
346
+ }
347
+
348
+ - (NSArray<NSString *> *)listPendingSessionDirs {
349
+ NSArray<NSString *> *dirs = [[NSFileManager defaultManager]
350
+ contentsOfDirectoryAtPath:self.pendingRootPath
351
+ error:nil];
352
+ if (![dirs isKindOfClass:[NSArray class]])
353
+ return @[];
354
+ NSMutableArray<NSString *> *out = [NSMutableArray new];
355
+ for (NSString *d in dirs) {
356
+ BOOL isDir = NO;
357
+ NSString *full = [self.pendingRootPath stringByAppendingPathComponent:d];
358
+ if ([[NSFileManager defaultManager] fileExistsAtPath:full
359
+ isDirectory:&isDir] &&
360
+ isDir) {
361
+ [out addObject:d];
362
+ }
363
+ }
364
+ return out;
365
+ }
366
+
367
+ - (void)recoverPendingSessionsWithCompletion:
368
+ (nullable RJCompletionHandler)completion {
369
+ dispatch_async(self.uploadQueue, ^{
370
+ NSArray<NSString *> *sessionDirs = [self listPendingSessionDirs];
371
+ if (sessionDirs.count == 0) {
372
+ if (completion)
373
+ completion(YES);
374
+ return;
375
+ }
376
+
377
+ NSString *originalSessionId = self.sessionId;
378
+ NSTimeInterval originalStart = self.sessionStartTime;
379
+ NSTimeInterval originalBg = self.totalBackgroundTimeMs;
380
+
381
+ BOOL allOk = YES;
382
+ for (NSString *sid in sessionDirs) {
383
+
384
+ if ([sid isEqualToString:originalSessionId]) {
385
+ RJLogDebug(@"Skipping current session during recovery: %@", sid);
386
+ continue;
387
+ }
388
+
389
+ NSString *dir = [self.pendingRootPath stringByAppendingPathComponent:sid];
390
+
391
+ NSString *metaPath = [dir stringByAppendingPathComponent:@"session.json"];
392
+ NSData *metaData = [NSData dataWithContentsOfFile:metaPath];
393
+ NSTimeInterval recoveredEndedAt = 0;
394
+ if (metaData) {
395
+ NSDictionary *meta = [NSJSONSerialization JSONObjectWithData:metaData
396
+ options:0
397
+ error:nil];
398
+ if ([meta isKindOfClass:[NSDictionary class]]) {
399
+ self.sessionStartTime = [meta[@"sessionStartTime"] doubleValue];
400
+ self.totalBackgroundTimeMs =
401
+ [meta[@"totalBackgroundTimeMs"] doubleValue];
402
+
403
+ if (meta[@"updatedAt"]) {
404
+ recoveredEndedAt = [meta[@"updatedAt"] doubleValue];
405
+ }
406
+ }
407
+ }
408
+
409
+ NSInteger crashCount = 0;
410
+ NSInteger anrCount = 0;
411
+ NSInteger errorCount = 0;
412
+ NSTimeInterval firstEventTs = 0;
413
+ NSTimeInterval lastEventTs = 0;
414
+ NSString *eventsPath =
415
+ [dir stringByAppendingPathComponent:@"events.jsonl"];
416
+ if ([[NSFileManager defaultManager] fileExistsAtPath:eventsPath]) {
417
+ NSString *content =
418
+ [NSString stringWithContentsOfFile:eventsPath
419
+ encoding:NSUTF8StringEncoding
420
+ error:nil];
421
+ if (content.length > 0) {
422
+ NSArray<NSString *> *lines =
423
+ [content componentsSeparatedByString:@"\n"];
424
+ for (NSString *line in lines) {
425
+ if (line.length == 0)
426
+ continue;
427
+ NSData *data = [line dataUsingEncoding:NSUTF8StringEncoding];
428
+ NSDictionary *event = [NSJSONSerialization JSONObjectWithData:data
429
+ options:0
430
+ error:nil];
431
+ if (event && event[@"timestamp"]) {
432
+ NSTimeInterval eventTs = [event[@"timestamp"] doubleValue];
433
+
434
+ if (firstEventTs == 0 || eventTs < firstEventTs)
435
+ firstEventTs = eventTs;
436
+ if (eventTs > lastEventTs)
437
+ lastEventTs = eventTs;
438
+
439
+ NSString *eventType = event[@"type"];
440
+ if ([eventType isEqualToString:@"crash"]) {
441
+ crashCount++;
442
+ } else if ([eventType isEqualToString:@"anr"]) {
443
+ anrCount++;
444
+ } else if ([eventType isEqualToString:@"error"]) {
445
+ errorCount++;
446
+ }
447
+ }
448
+ }
449
+
450
+ if (lastEventTs > recoveredEndedAt) {
451
+ recoveredEndedAt = lastEventTs;
452
+ RJLogDebug(@"Using last event timestamp from events.jsonl: %.0f",
453
+ lastEventTs);
454
+ }
455
+ }
456
+ }
457
+
458
+ self.sessionId = sid;
459
+
460
+ NSArray<NSString *> *files =
461
+ [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dir
462
+ error:nil]
463
+ ?: @[];
464
+
465
+ NSArray<NSString *> *gzFiles =
466
+ [[files filteredArrayUsingPredicate:
467
+ [NSPredicate predicateWithFormat:@"SELF ENDSWITH '.gz'"]]
468
+ sortedArrayUsingSelector:@selector(compare:)];
469
+
470
+ for (NSString *name in gzFiles) {
471
+ NSDictionary *parsed = [self parsePendingFilename:name];
472
+ if (!parsed)
473
+ continue;
474
+
475
+ NSString *path = [dir stringByAppendingPathComponent:name];
476
+ NSData *gz = [NSData dataWithContentsOfFile:path];
477
+ if (!gz) {
478
+ allOk = NO;
479
+ continue;
480
+ }
481
+
482
+ NSString *metaFile = [path stringByAppendingString:@".meta.json"];
483
+ NSData *m = [NSData dataWithContentsOfFile:metaFile];
484
+ NSDictionary *meta = m ? [NSJSONSerialization JSONObjectWithData:m
485
+ options:0
486
+ error:nil]
487
+ : @{};
488
+ NSInteger eventCount = [meta[@"eventCount"] integerValue];
489
+ NSInteger frameCount = [meta[@"frameCount"] integerValue];
490
+ BOOL isKeyframe = meta[@"isKeyframe"]
491
+ ? [meta[@"isKeyframe"] boolValue]
492
+ : [parsed[@"isKeyframe"] boolValue];
493
+
494
+ NSString *contentType = parsed[@"contentType"];
495
+ NSInteger batchNumber = [parsed[@"batchNumber"] integerValue];
496
+
497
+ RJLogDebug(@"Recovery: uploading pending %@ batch=%ld keyframe=%@ "
498
+ @"bytes=%lu (events=%ld frames=%ld)",
499
+ contentType, (long)batchNumber, isKeyframe ? @"YES" : @"NO",
500
+ (unsigned long)gz.length, (long)eventCount,
501
+ (long)frameCount);
502
+
503
+ NSDictionary *presign = nil;
504
+ BOOL presignOk = [self presignForContentType:contentType
505
+ batchNumber:batchNumber
506
+ sizeBytes:gz.length
507
+ isKeyframe:isKeyframe
508
+ result:&presign];
509
+ if (!presignOk || ![presign isKindOfClass:[NSDictionary class]]) {
510
+ allOk = NO;
511
+ continue;
512
+ }
513
+
514
+ NSString *uploadUrl = presign[@"presignedUrl"];
515
+ NSString *batchId = presign[@"batchId"];
516
+ if (![self uploadData:gz
517
+ toPresignedURL:uploadUrl
518
+ contentType:@"application/gzip"]) {
519
+ allOk = NO;
520
+ continue;
521
+ }
522
+
523
+ BOOL completeOk = [self completeBatchWithId:batchId
524
+ actualSizeBytes:gz.length
525
+ eventCount:eventCount
526
+ frameCount:frameCount];
527
+ if (completeOk) {
528
+ [[NSFileManager defaultManager] removeItemAtPath:path error:nil];
529
+ [[NSFileManager defaultManager] removeItemAtPath:metaFile error:nil];
530
+ } else {
531
+ allOk = NO;
532
+ }
533
+ }
534
+
535
+ NSArray<NSString *> *remaining =
536
+ [[[NSFileManager defaultManager] contentsOfDirectoryAtPath:dir
537
+ error:nil]
538
+ ?: @[]
539
+ filteredArrayUsingPredicate:
540
+ [NSPredicate predicateWithFormat:@"SELF ENDSWITH '.gz'"]];
541
+ if (remaining.count == 0) {
542
+
543
+ BOOL endOk =
544
+ (recoveredEndedAt > 0)
545
+ ? [self endSessionSyncWithEndedAt:recoveredEndedAt
546
+ timeout:RJNetworkRequestTimeout]
547
+ : [self endSessionSync];
548
+ if (endOk) {
549
+ [self clearSessionRecovery:sid];
550
+ } else {
551
+ allOk = NO;
552
+ }
553
+ } else {
554
+ allOk = NO;
555
+ }
556
+ }
557
+
558
+ self.sessionId = originalSessionId;
559
+ self.sessionStartTime = originalStart;
560
+ self.totalBackgroundTimeMs = originalBg;
561
+
562
+ if (completion)
563
+ completion(allOk);
564
+ });
565
+ }
566
+
567
+ #pragma mark - Project Resolution
568
+
569
+ - (void)fetchProjectConfigWithCompletion:
570
+ (void (^)(BOOL success, NSDictionary *_Nullable config,
571
+ NSError *_Nullable error))completion {
572
+
573
+ if (!self.publicKey || self.publicKey.length == 0) {
574
+ RJLogWarning(@"Cannot fetch project config: no publicKey set");
575
+ if (completion) {
576
+ completion(NO, nil,
577
+ [NSError
578
+ errorWithDomain:@"RJUploadManager"
579
+ code:1001
580
+ userInfo:@{
581
+ NSLocalizedDescriptionKey : @"No public key set"
582
+ }]);
583
+ }
584
+ return;
585
+ }
586
+
587
+ NSString *urlString =
588
+ [NSString stringWithFormat:@"%@/api/sdk/config", self.apiUrl];
589
+
590
+ dispatch_async(
591
+ dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
592
+ NSURL *url = [NSURL URLWithString:urlString];
593
+ if (!url) {
594
+ dispatch_async(dispatch_get_main_queue(), ^{
595
+ if (completion) {
596
+ completion(NO, nil,
597
+ [NSError
598
+ errorWithDomain:@"RJUploadManager"
599
+ code:1002
600
+ userInfo:@{
601
+ NSLocalizedDescriptionKey : @"Invalid URL"
602
+ }]);
603
+ }
604
+ });
605
+ return;
606
+ }
607
+
608
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
609
+ request.HTTPMethod = @"GET";
610
+ request.timeoutInterval = RJNetworkRequestTimeout;
611
+
612
+ NSDictionary *headers = [self configHeaders];
613
+ for (NSString *key in headers.allKeys) {
614
+ [request setValue:headers[key] forHTTPHeaderField:key];
615
+ }
616
+
617
+ NSURLSessionConfiguration *config =
618
+ [NSURLSessionConfiguration defaultSessionConfiguration];
619
+ config.timeoutIntervalForRequest = RJNetworkRequestTimeout;
620
+ config.timeoutIntervalForResource = RJNetworkRequestTimeout;
621
+ NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
622
+
623
+ NSURLSessionDataTask *task = [session
624
+ dataTaskWithRequest:request
625
+ completionHandler:^(NSData *data, NSURLResponse *response,
626
+ NSError *error) {
627
+ dispatch_async(dispatch_get_main_queue(), ^{
628
+ if (error) {
629
+ RJLogError(@"Failed to fetch project configuration: %@",
630
+ error.localizedDescription);
631
+ if (completion) {
632
+ completion(NO, nil, error);
633
+ }
634
+ return;
635
+ }
636
+
637
+ if (![response isKindOfClass:[NSHTTPURLResponse class]]) {
638
+ RJLogError(@"Invalid response type");
639
+ if (completion) {
640
+ completion(NO, nil,
641
+ [NSError errorWithDomain:@"RJUploadManager"
642
+ code:1002
643
+ userInfo:@{
644
+ NSLocalizedDescriptionKey :
645
+ @"Invalid response type"
646
+ }]);
647
+ }
648
+ return;
649
+ }
650
+
651
+ NSHTTPURLResponse *httpResponse =
652
+ (NSHTTPURLResponse *)response;
653
+ if (httpResponse.statusCode < 200 ||
654
+ httpResponse.statusCode >= 300) {
655
+ RJLogError(
656
+ @"Failed to fetch project configuration: status %ld",
657
+ (long)httpResponse.statusCode);
658
+ if (completion) {
659
+ completion(
660
+ NO, nil,
661
+ [NSError
662
+ errorWithDomain:@"RJUploadManager"
663
+ code:1002
664
+ userInfo:@{
665
+ NSLocalizedDescriptionKey : [NSString
666
+ stringWithFormat:@"HTTP %ld",
667
+ (long)httpResponse
668
+ .statusCode]
669
+ }]);
670
+ }
671
+ return;
672
+ }
673
+
674
+ NSDictionary *responseDict = nil;
675
+ if (data) {
676
+ NSError *parseError = nil;
677
+ responseDict =
678
+ [NSJSONSerialization JSONObjectWithData:data
679
+ options:0
680
+ error:&parseError];
681
+ if (parseError ||
682
+ ![responseDict isKindOfClass:[NSDictionary class]]) {
683
+ RJLogError(
684
+ @"Failed to parse project configuration response");
685
+ if (completion) {
686
+ completion(NO, nil, parseError);
687
+ }
688
+ return;
689
+ }
690
+ }
691
+
692
+ NSDictionary *response = responseDict;
693
+ if (!response ||
694
+ ![response isKindOfClass:[NSDictionary class]]) {
695
+ RJLogError(@"Failed to fetch project configuration: "
696
+ @"invalid response");
697
+ if (completion) {
698
+ completion(NO, nil,
699
+ [NSError errorWithDomain:@"RJUploadManager"
700
+ code:1002
701
+ userInfo:@{
702
+ NSLocalizedDescriptionKey :
703
+ @"Invalid response"
704
+ }]);
705
+ }
706
+ return;
707
+ }
708
+
709
+ NSString *resolvedProjectId = response[@"projectId"];
710
+ if (resolvedProjectId && resolvedProjectId.length > 0) {
711
+ self.projectId = resolvedProjectId;
712
+ RJLogDebug(@"Resolved projectId: %@", resolvedProjectId);
713
+ }
714
+
715
+ if (response[@"maxRecordingMinutes"]) {
716
+ self.maxRecordingMinutes =
717
+ [response[@"maxRecordingMinutes"] integerValue];
718
+ RJLogDebug(@"Updated maxRecordingMinutes: %ld",
719
+ (long)self.maxRecordingMinutes);
720
+ }
721
+
722
+ if (response[@"sampleRate"]) {
723
+ self.sampleRate = [response[@"sampleRate"] integerValue];
724
+ }
725
+
726
+ BOOL recordingEnabled = YES;
727
+ if (response[@"recordingEnabled"] &&
728
+ [response[@"recordingEnabled"] boolValue] == NO) {
729
+ recordingEnabled = NO;
730
+ RJLogWarning(@"Recording is disabled for this project");
731
+ }
732
+
733
+ BOOL rejourneyEnabled = YES;
734
+ if (response[@"rejourneyEnabled"] &&
735
+ [response[@"rejourneyEnabled"] boolValue] == NO) {
736
+ rejourneyEnabled = NO;
737
+ RJLogWarning(@"Rejourney is disabled for this project");
738
+ }
739
+
740
+ BOOL billingBlocked = NO;
741
+ if (response[@"billingBlocked"] &&
742
+ [response[@"billingBlocked"] boolValue] == YES) {
743
+ billingBlocked = YES;
744
+ RJLogWarning(@"Session limit reached - recording blocked "
745
+ @"by billing");
746
+ }
747
+
748
+ if (completion) {
749
+ NSMutableDictionary *config = [response mutableCopy];
750
+ config[@"recordingEnabled"] = @(recordingEnabled);
751
+ config[@"rejourneyEnabled"] = @(rejourneyEnabled);
752
+ config[@"billingBlocked"] = @(billingBlocked);
753
+ completion(YES, config, nil);
754
+ }
755
+ });
756
+ }];
757
+
758
+ [task resume];
759
+ [session finishTasksAndInvalidate];
760
+ });
761
+ }
762
+
763
+ - (BOOL)resolveProjectIdFromPublicKey {
764
+
765
+ if (self.projectId.length > 0) {
766
+ return YES;
767
+ }
768
+
769
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
770
+ __block BOOL success = NO;
771
+
772
+ [self fetchProjectConfigWithCompletion:^(BOOL ok, NSDictionary *config,
773
+ NSError *error) {
774
+ success = ok;
775
+ dispatch_semaphore_signal(sema);
776
+ }];
777
+
778
+ dispatch_semaphore_wait(sema,
779
+ dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC));
780
+ return success;
781
+ }
782
+
783
+ #pragma mark - Timer Management
784
+
785
+ - (void)startBatchUploadTimer {
786
+ [self stopBatchUploadTimer];
787
+
788
+ __weak typeof(self) weakSelf = self;
789
+
790
+ self.batchUploadTimer =
791
+ [NSTimer scheduledTimerWithTimeInterval:RJBatchUploadInterval
792
+ repeats:YES
793
+ block:^(NSTimer *timer) {
794
+ [weakSelf timerFired];
795
+ }];
796
+ }
797
+
798
+ - (void)stopBatchUploadTimer {
799
+ [self.batchUploadTimer invalidate];
800
+ self.batchUploadTimer = nil;
801
+ }
802
+
803
+ - (void)timerFired {
804
+ }
805
+
806
+ #pragma mark - Upload Methods
807
+
808
+ - (void)uploadBatchWithEvents:(NSArray<NSDictionary *> *)events
809
+ isFinal:(BOOL)isFinal
810
+ completion:(RJCompletionHandler)completion {
811
+
812
+ RJCompletionHandler safeCompletion = [completion copy];
813
+
814
+ if (self.isShuttingDown) {
815
+ if (safeCompletion)
816
+ safeCompletion(NO);
817
+ return;
818
+ }
819
+
820
+ if (self.isUploading) {
821
+ RJLogDebug(@"Upload already in progress, queueing batch for retry");
822
+
823
+ [self addToRetryQueueWithEvents:(events ?: @[])];
824
+ if (safeCompletion)
825
+ safeCompletion(NO);
826
+ return;
827
+ }
828
+
829
+ NSArray *safeEvents = events ?: @[];
830
+
831
+ if (safeEvents.count == 0) {
832
+ if (safeCompletion)
833
+ safeCompletion(YES);
834
+ return;
835
+ }
836
+
837
+ self.isUploading = YES;
838
+ self.batchNumber++;
839
+
840
+ NSInteger currentBatch = self.batchNumber;
841
+ RJLogDebug(@"Batch %ld: start upload (sessionId=%@ isFinal=%@) events=%lu "
842
+ @"retryQueue=%lu circuitOpen=%@",
843
+ (long)currentBatch, self.sessionId ?: @"",
844
+ isFinal ? @"YES" : @"NO", (unsigned long)safeEvents.count,
845
+ (unsigned long)self.retryQueue.count,
846
+ self.isCircuitOpen ? @"YES" : @"NO");
847
+
848
+ __weak typeof(self) weakSelf = self;
849
+ dispatch_async(self.uploadQueue, ^{
850
+ __strong typeof(weakSelf) strongSelf = weakSelf;
851
+ BOOL success = NO;
852
+
853
+ @try {
854
+ success = [strongSelf uploadEventsSync:safeEvents isFinal:isFinal];
855
+
856
+ // NOTE: Don't call endSessionSync here - uploadEventsSync already calls
857
+ // it when isFinal:YES is passed. The duplicate call was causing two
858
+ // session/end requests to be sent to the backend.
859
+ if (success && isFinal) {
860
+ RJLogDebug(
861
+ @"Final batch uploaded (session end handled by uploadEventsSync)");
862
+ }
863
+
864
+ } @catch (NSException *exception) {
865
+ RJLogError(@"Batch upload failed: %@", exception);
866
+ success = NO;
867
+ }
868
+
869
+ dispatch_async(dispatch_get_main_queue(), ^{
870
+ if (strongSelf) {
871
+ strongSelf.isUploading = NO;
872
+ if (success) {
873
+ [strongSelf recordUploadSuccess];
874
+ strongSelf.lastUploadTime = [[NSDate date] timeIntervalSince1970];
875
+ RJLogDebug(@"Batch %ld uploaded successfully", (long)currentBatch);
876
+ } else {
877
+ [strongSelf recordUploadFailure];
878
+ RJLogWarning(@"Batch %ld upload failed, adding to retry queue",
879
+ (long)currentBatch);
880
+
881
+ [strongSelf addToRetryQueueWithEvents:safeEvents];
882
+ }
883
+ }
884
+ if (safeCompletion)
885
+ safeCompletion(success);
886
+ });
887
+ });
888
+ }
889
+
890
+ - (BOOL)synchronousUploadWithEvents:(NSArray<NSDictionary *> *)events {
891
+
892
+ NSArray *safeEvents = events ?: @[];
893
+
894
+ BOOL uploadSuccess = YES;
895
+
896
+ if (safeEvents.count > 0) {
897
+
898
+ if (self.isUploading) {
899
+ RJLogDebug(@"Waiting for in-progress upload to complete...");
900
+ NSTimeInterval waitStart = [[NSDate date] timeIntervalSince1970];
901
+ while (self.isUploading) {
902
+ [[NSRunLoop currentRunLoop]
903
+ runMode:NSDefaultRunLoopMode
904
+ beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
905
+ NSTimeInterval elapsed =
906
+ [[NSDate date] timeIntervalSince1970] - waitStart;
907
+ if (elapsed > 10.0) {
908
+ RJLogWarning(@"Upload wait timeout (10s), proceeding...");
909
+ break;
910
+ }
911
+ }
912
+ if (!self.isUploading) {
913
+ RJLogDebug(@"In-progress upload completed during wait");
914
+ }
915
+ }
916
+
917
+ if (self.isUploading) {
918
+ RJLogWarning(
919
+ @"Upload still in progress after wait, sending fast session-end...");
920
+
921
+ [self endSessionSyncWithTimeout:2.0];
922
+ return NO;
923
+ }
924
+
925
+ self.isUploading = YES;
926
+ self.batchNumber++;
927
+
928
+ @try {
929
+ uploadSuccess = [self uploadEventsSync:safeEvents isFinal:YES];
930
+ if (uploadSuccess) {
931
+ self.lastUploadTime = [[NSDate date] timeIntervalSince1970];
932
+ RJLogDebug(@"Termination upload sent");
933
+ } else {
934
+ RJLogWarning(@"Termination upload failed");
935
+ }
936
+ } @catch (NSException *exception) {
937
+ RJLogError(@"Synchronous upload exception: %@", exception);
938
+ uploadSuccess = NO;
939
+ }
940
+
941
+ self.isUploading = NO;
942
+ }
943
+
944
+ // NOTE: Don't call endSessionSync here - uploadEventsSync already calls it
945
+ // when isFinal:YES is passed. The duplicate call was causing two session/end
946
+ // requests to be sent to the backend.
947
+ //
948
+ // Previously this had:
949
+ // RJLogDebug(@"Sending session end signal...");
950
+ // BOOL endSessionSuccess = [self endSessionSync];
951
+ // return uploadSuccess && endSessionSuccess;
952
+
953
+ return uploadSuccess;
954
+ }
955
+
956
+ - (void)uploadCrashReport:(NSDictionary *)report
957
+ completion:(nullable RJCompletionHandler)completion {
958
+ if (!report) {
959
+ if (completion)
960
+ completion(NO);
961
+ return;
962
+ }
963
+
964
+ dispatch_async(self.uploadQueue, ^{
965
+ NSString *originalSessionId = self.sessionId;
966
+ BOOL didTemporarilyChangeSessionId = NO;
967
+
968
+ @try {
969
+
970
+ NSString *crashSessionId = report[@"sessionId"];
971
+ if (crashSessionId && crashSessionId.length > 0) {
972
+
973
+ self.sessionId = crashSessionId;
974
+ didTemporarilyChangeSessionId = YES;
975
+ RJLogDebug(@"Using sessionId from crash report: %@", crashSessionId);
976
+ }
977
+
978
+ NSDictionary *payload = @{
979
+ @"crashes" : @[ report ],
980
+ @"sessionId" : crashSessionId ?: @"",
981
+ @"timestamp" : report[@"timestamp"]
982
+ ?: @([[NSDate date] timeIntervalSince1970] * 1000)
983
+ };
984
+
985
+ NSError *error = nil;
986
+ NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload
987
+ options:0
988
+ error:&error];
989
+ if (error || !jsonData) {
990
+ RJLogError(@"Failed to serialize crash report: %@", error);
991
+
992
+ if (didTemporarilyChangeSessionId) {
993
+ self.sessionId = originalSessionId;
994
+ }
995
+ if (completion)
996
+ completion(NO);
997
+ return;
998
+ }
999
+
1000
+ NSData *gzipped = RJGzipData(jsonData, &error);
1001
+ if (!gzipped) {
1002
+ RJLogError(@"Failed to gzip crash report: %@", error);
1003
+
1004
+ if (didTemporarilyChangeSessionId) {
1005
+ self.sessionId = originalSessionId;
1006
+ }
1007
+ if (completion)
1008
+ completion(NO);
1009
+ return;
1010
+ }
1011
+
1012
+ NSDictionary *presignResult = nil;
1013
+ BOOL presigned = [self presignForContentType:@"crashes"
1014
+ batchNumber:0
1015
+ sizeBytes:gzipped.length
1016
+ isKeyframe:NO
1017
+ result:&presignResult];
1018
+
1019
+ if (!presigned || !presignResult) {
1020
+ RJLogError(@"Failed to presign crash report");
1021
+
1022
+ if (didTemporarilyChangeSessionId) {
1023
+ self.sessionId = originalSessionId;
1024
+ }
1025
+ if (completion)
1026
+ completion(NO);
1027
+ return;
1028
+ }
1029
+
1030
+ NSString *uploadUrl = presignResult[@"presignedUrl"];
1031
+ NSString *batchId = presignResult[@"batchId"];
1032
+
1033
+ if (!uploadUrl || uploadUrl.length == 0) {
1034
+ RJLogError(@"Invalid presigned URL - received: %@", presignResult);
1035
+
1036
+ if (didTemporarilyChangeSessionId) {
1037
+ self.sessionId = originalSessionId;
1038
+ }
1039
+ if (completion)
1040
+ completion(NO);
1041
+ return;
1042
+ }
1043
+
1044
+ if (![self uploadData:gzipped
1045
+ toPresignedURL:uploadUrl
1046
+ contentType:@"application/gzip"]) {
1047
+ RJLogError(@"Failed to upload crash report to S3");
1048
+
1049
+ if (didTemporarilyChangeSessionId) {
1050
+ self.sessionId = originalSessionId;
1051
+ }
1052
+ if (completion)
1053
+ completion(NO);
1054
+ return;
1055
+ }
1056
+
1057
+ if (![self completeBatchWithId:batchId
1058
+ actualSizeBytes:gzipped.length
1059
+ eventCount:0
1060
+ frameCount:0]) {
1061
+ RJLogWarning(@"Failed to complete crash report batch");
1062
+ }
1063
+
1064
+ RJLogDebug(@"Crash report uploaded successfully");
1065
+
1066
+ if (didTemporarilyChangeSessionId) {
1067
+ self.sessionId = originalSessionId;
1068
+ }
1069
+
1070
+ if (completion)
1071
+ completion(YES);
1072
+
1073
+ } @catch (NSException *exception) {
1074
+ RJLogError(@"Crash upload exception: %@", exception);
1075
+
1076
+ if (didTemporarilyChangeSessionId) {
1077
+ self.sessionId = originalSessionId;
1078
+ }
1079
+ if (completion)
1080
+ completion(NO);
1081
+ }
1082
+ });
1083
+ }
1084
+
1085
+ - (void)uploadANRReport:(NSDictionary *)report
1086
+ completion:(nullable RJCompletionHandler)completion {
1087
+ if (!report) {
1088
+ if (completion)
1089
+ completion(NO);
1090
+ return;
1091
+ }
1092
+
1093
+ dispatch_async(self.uploadQueue, ^{
1094
+ NSString *originalSessionId = self.sessionId;
1095
+ BOOL didTemporarilyChangeSessionId = NO;
1096
+
1097
+ @try {
1098
+ NSString *anrSessionId = report[@"sessionId"];
1099
+ if (anrSessionId && anrSessionId.length > 0) {
1100
+ self.sessionId = anrSessionId;
1101
+ didTemporarilyChangeSessionId = YES;
1102
+ RJLogDebug(@"Using sessionId from ANR report: %@", anrSessionId);
1103
+ }
1104
+
1105
+ NSDictionary *payload = @{
1106
+ @"anrs" : @[ report ],
1107
+ @"sessionId" : anrSessionId ?: @"",
1108
+ @"timestamp" : report[@"timestamp"]
1109
+ ?: @([[NSDate date] timeIntervalSince1970] * 1000)
1110
+ };
1111
+
1112
+ NSError *error = nil;
1113
+ NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload
1114
+ options:0
1115
+ error:&error];
1116
+ if (error || !jsonData) {
1117
+ RJLogError(@"Failed to serialize ANR report: %@", error);
1118
+ if (didTemporarilyChangeSessionId)
1119
+ self.sessionId = originalSessionId;
1120
+ if (completion)
1121
+ completion(NO);
1122
+ return;
1123
+ }
1124
+
1125
+ NSData *gzipped = RJGzipData(jsonData, &error);
1126
+ if (!gzipped) {
1127
+ RJLogError(@"Failed to gzip ANR report: %@", error);
1128
+ if (didTemporarilyChangeSessionId)
1129
+ self.sessionId = originalSessionId;
1130
+ if (completion)
1131
+ completion(NO);
1132
+ return;
1133
+ }
1134
+
1135
+ NSDictionary *presignResult = nil;
1136
+ BOOL presigned = [self presignForContentType:@"anrs"
1137
+ batchNumber:0
1138
+ sizeBytes:gzipped.length
1139
+ isKeyframe:NO
1140
+ result:&presignResult];
1141
+
1142
+ if (!presigned || !presignResult) {
1143
+ RJLogError(@"Failed to presign ANR report");
1144
+ if (didTemporarilyChangeSessionId)
1145
+ self.sessionId = originalSessionId;
1146
+ if (completion)
1147
+ completion(NO);
1148
+ return;
1149
+ }
1150
+
1151
+ NSString *uploadUrl = presignResult[@"presignedUrl"];
1152
+ NSString *batchId = presignResult[@"batchId"];
1153
+
1154
+ if (!uploadUrl || uploadUrl.length == 0) {
1155
+ RJLogError(@"Invalid presigned URL for ANR");
1156
+ if (didTemporarilyChangeSessionId)
1157
+ self.sessionId = originalSessionId;
1158
+ if (completion)
1159
+ completion(NO);
1160
+ return;
1161
+ }
1162
+
1163
+ if (![self uploadData:gzipped
1164
+ toPresignedURL:uploadUrl
1165
+ contentType:@"application/gzip"]) {
1166
+ RJLogError(@"Failed to upload ANR report to S3");
1167
+ if (didTemporarilyChangeSessionId)
1168
+ self.sessionId = originalSessionId;
1169
+ if (completion)
1170
+ completion(NO);
1171
+ return;
1172
+ }
1173
+
1174
+ [self completeBatchWithId:batchId
1175
+ actualSizeBytes:gzipped.length
1176
+ eventCount:0
1177
+ frameCount:0];
1178
+
1179
+ RJLogDebug(@"ANR report uploaded successfully");
1180
+ if (didTemporarilyChangeSessionId)
1181
+ self.sessionId = originalSessionId;
1182
+ if (completion)
1183
+ completion(YES);
1184
+
1185
+ } @catch (NSException *exception) {
1186
+ RJLogError(@"ANR upload exception: %@", exception);
1187
+ if (didTemporarilyChangeSessionId)
1188
+ self.sessionId = originalSessionId;
1189
+ if (completion)
1190
+ completion(NO);
1191
+ }
1192
+ });
1193
+ }
1194
+
1195
+ #pragma mark - Payload Building
1196
+
1197
+ - (NSDictionary *)buildEventPayloadWithEvents:(NSArray<NSDictionary *> *)events
1198
+ batchNumber:(NSInteger)batchNumber
1199
+ isFinal:(BOOL)isFinal {
1200
+
1201
+ @try {
1202
+ NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
1203
+ NSDictionary *deviceInfo = [self buildDeviceInfo];
1204
+
1205
+ NSString *sessionId = self.sessionId;
1206
+ NSString *userId = self.userId;
1207
+ NSTimeInterval sessionStartTime = self.sessionStartTime;
1208
+
1209
+ NSMutableDictionary *payload =
1210
+ [NSMutableDictionary dictionaryWithCapacity:12];
1211
+
1212
+ payload[@"sessionId"] = sessionId ?: @"";
1213
+ payload[@"userId"] = (userId && userId.length > 0) ? userId : @"anonymous";
1214
+ payload[@"batchNumber"] = @(batchNumber);
1215
+ payload[@"isFinal"] = @(isFinal);
1216
+ payload[@"sessionStartTime"] = @(sessionStartTime * 1000);
1217
+ payload[@"batchTime"] = @(currentTime * 1000);
1218
+ payload[@"deviceInfo"] = deviceInfo ?: @{};
1219
+ payload[@"events"] = events ?: @[];
1220
+
1221
+ if (isFinal) {
1222
+ payload[@"endTime"] = @(currentTime * 1000);
1223
+ NSTimeInterval duration = currentTime - sessionStartTime;
1224
+ payload[@"duration"] = @(MAX(0, duration) * 1000);
1225
+ }
1226
+
1227
+ return [payload copy];
1228
+ } @catch (NSException *exception) {
1229
+ RJLogError(@"Payload building failed: %@", exception);
1230
+ return nil;
1231
+ }
1232
+ }
1233
+
1234
+ - (NSDictionary *)buildDeviceInfo {
1235
+ @try {
1236
+ UIDevice *device = [UIDevice currentDevice];
1237
+ CGRect screenBounds = [UIScreen mainScreen].bounds;
1238
+ CGFloat screenScale = [UIScreen mainScreen].scale;
1239
+
1240
+ NSBundle *mainBundle = [NSBundle mainBundle];
1241
+ NSString *appVersion =
1242
+ [mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"];
1243
+ if (!appVersion || appVersion.length == 0) {
1244
+
1245
+ appVersion = [mainBundle objectForInfoDictionaryKey:@"CFBundleVersion"];
1246
+ }
1247
+ if (!appVersion || appVersion.length == 0) {
1248
+ appVersion = nil;
1249
+ }
1250
+
1251
+ NSString *bundleId = [mainBundle bundleIdentifier];
1252
+
1253
+ NSMutableDictionary *deviceInfo =
1254
+ [NSMutableDictionary dictionaryWithDictionary:@{
1255
+ @"model" : [self deviceModelIdentifier] ?: device.model ?: @"unknown",
1256
+ @"systemName" : device.systemName ?: @"iOS",
1257
+ @"systemVersion" : device.systemVersion ?: @"unknown",
1258
+ @"name" : device.name ?: @"iPhone",
1259
+ @"screenWidth" : @(screenBounds.size.width),
1260
+ @"screenHeight" : @(screenBounds.size.height),
1261
+ @"screenScale" : @(screenScale),
1262
+ @"platform" : @"ios"
1263
+ }];
1264
+
1265
+ if (appVersion) {
1266
+ deviceInfo[@"appVersion"] = appVersion;
1267
+ }
1268
+
1269
+ if (bundleId) {
1270
+ deviceInfo[@"appId"] = bundleId;
1271
+ }
1272
+
1273
+ if (self.deviceHash && self.deviceHash.length > 0) {
1274
+ deviceInfo[@"deviceHash"] = self.deviceHash;
1275
+ }
1276
+
1277
+ RJNetworkQuality *networkQuality =
1278
+ [[RJNetworkMonitor sharedInstance] captureNetworkQuality];
1279
+ if (networkQuality) {
1280
+ NSDictionary *networkDict = [networkQuality toDictionary];
1281
+ if (networkDict[@"networkType"]) {
1282
+ deviceInfo[@"networkType"] = networkDict[@"networkType"];
1283
+ }
1284
+ if (networkDict[@"cellularGeneration"] &&
1285
+ ![networkDict[@"cellularGeneration"] isEqualToString:@"unknown"]) {
1286
+ deviceInfo[@"cellularGeneration"] = networkDict[@"cellularGeneration"];
1287
+ }
1288
+ deviceInfo[@"isConstrained"] = networkDict[@"isConstrained"] ?: @(NO);
1289
+ deviceInfo[@"isExpensive"] = networkDict[@"isExpensive"] ?: @(NO);
1290
+ }
1291
+
1292
+ return [deviceInfo copy];
1293
+ } @catch (NSException *exception) {
1294
+ RJLogWarning(@"Device info collection failed: %@", exception);
1295
+ return @{};
1296
+ }
1297
+ }
1298
+
1299
+ - (NSString *)deviceModelIdentifier {
1300
+ struct utsname systemInfo;
1301
+ uname(&systemInfo);
1302
+ return [NSString stringWithCString:systemInfo.machine
1303
+ encoding:NSUTF8StringEncoding];
1304
+ }
1305
+
1306
+ #pragma mark - Presigned Upload Helpers
1307
+
1308
+ - (NSDictionary *)configHeaders {
1309
+ NSMutableDictionary *headers = [NSMutableDictionary dictionary];
1310
+
1311
+ if (self.publicKey.length > 0) {
1312
+ headers[@"x-public-key"] = self.publicKey;
1313
+ }
1314
+
1315
+ NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier];
1316
+ if (bundleId.length > 0) {
1317
+ headers[@"x-bundle-id"] = bundleId;
1318
+ }
1319
+
1320
+ headers[@"x-platform"] = @"ios";
1321
+
1322
+ return headers;
1323
+ }
1324
+
1325
+ - (NSDictionary *)authHeaders {
1326
+ NSMutableDictionary *headers = [NSMutableDictionary dictionary];
1327
+
1328
+ if (self.publicKey.length > 0) {
1329
+ headers[@"X-Rejourney-Key"] = self.publicKey;
1330
+ }
1331
+
1332
+ NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier];
1333
+ if (bundleId.length > 0) {
1334
+ headers[@"X-Bundle-ID"] = bundleId;
1335
+ }
1336
+
1337
+ headers[@"X-Rejourney-Platform"] = @"ios";
1338
+
1339
+ if (self.deviceHash.length > 0) {
1340
+ headers[@"X-Rejourney-Device-Hash"] = self.deviceHash;
1341
+ }
1342
+
1343
+ if (self.deviceAuthManager) {
1344
+ NSString *uploadToken = [self.deviceAuthManager currentUploadToken];
1345
+ if (uploadToken.length > 0) {
1346
+ headers[@"x-upload-token"] = uploadToken;
1347
+ RJLogDebug(@"Added upload token to request headers");
1348
+ } else {
1349
+ RJLogWarning(@"No valid upload token available - triggering async "
1350
+ @"refresh with auto-register");
1351
+
1352
+ [self.deviceAuthManager getUploadTokenWithAutoRegisterCompletion:^(
1353
+ BOOL success, NSString *token,
1354
+ NSInteger expiresIn, NSError *error) {
1355
+ if (success) {
1356
+ RJLogDebug(
1357
+ @"Background token refresh completed (expires in %ld seconds)",
1358
+ (long)expiresIn);
1359
+ } else {
1360
+ RJLogWarning(@"Background token refresh failed: %@", error);
1361
+ }
1362
+ }];
1363
+ }
1364
+ }
1365
+
1366
+ return headers;
1367
+ }
1368
+
1369
+ - (BOOL)sendJSONRequestTo:(NSString *)urlString
1370
+ method:(NSString *)method
1371
+ body:(NSDictionary *)body
1372
+ timeoutSec:(NSTimeInterval)timeout
1373
+ retryCount:(NSInteger)retryCount
1374
+ responseJSON:(NSDictionary *__autoreleasing *)responseJSON {
1375
+
1376
+ NSURL *url = [NSURL URLWithString:urlString];
1377
+ if (!url) {
1378
+ RJLogError(@"Invalid URL: %@", urlString);
1379
+ return NO;
1380
+ }
1381
+
1382
+ NSError *jsonError = nil;
1383
+ NSData *bodyData = nil;
1384
+ if (body) {
1385
+ bodyData = [NSJSONSerialization dataWithJSONObject:body
1386
+ options:0
1387
+ error:&jsonError];
1388
+ if (jsonError) {
1389
+ RJLogError(@"Failed to serialize JSON: %@", jsonError);
1390
+ return NO;
1391
+ }
1392
+ }
1393
+
1394
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
1395
+ request.HTTPMethod = method ?: @"POST";
1396
+ request.timeoutInterval = timeout;
1397
+ if (bodyData) {
1398
+ request.HTTPBody = bodyData;
1399
+ [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
1400
+ }
1401
+
1402
+ RJLogDebug(@"HTTP %@ %@ (bodyBytes=%lu timeout=%.1fs retry=%ld)",
1403
+ request.HTTPMethod ?: @"<nil>", RJRedactedURLForLogFromURL(url),
1404
+ (unsigned long)(bodyData ? bodyData.length : 0), timeout,
1405
+ (long)retryCount);
1406
+
1407
+ NSDictionary *headers = [self authHeaders];
1408
+ for (NSString *key in headers.allKeys) {
1409
+ [request setValue:headers[key] forHTTPHeaderField:key];
1410
+ }
1411
+
1412
+ __block BOOL success = NO;
1413
+ __block NSData *responseData = nil;
1414
+ __block NSInteger statusCode = 0;
1415
+ __block NSString *errorDesc = nil;
1416
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
1417
+
1418
+ NSURLSessionConfiguration *config =
1419
+ [NSURLSessionConfiguration defaultSessionConfiguration];
1420
+ config.timeoutIntervalForRequest = timeout;
1421
+ config.timeoutIntervalForResource = timeout;
1422
+
1423
+ NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
1424
+
1425
+ NSURLSessionDataTask *task = [session
1426
+ dataTaskWithRequest:request
1427
+ completionHandler:^(NSData *data, NSURLResponse *response,
1428
+ NSError *error) {
1429
+ if (!error && [response isKindOfClass:[NSHTTPURLResponse class]]) {
1430
+ NSHTTPURLResponse *http = (NSHTTPURLResponse *)response;
1431
+ statusCode = http.statusCode;
1432
+ success = (statusCode >= 200 && statusCode < 300);
1433
+ responseData = data;
1434
+ } else {
1435
+ errorDesc = error.localizedDescription;
1436
+ RJLogError(@"HTTP %@ %@ failed: %@", request.HTTPMethod ?: @"<nil>",
1437
+ RJRedactedURLForLogFromURL(url),
1438
+ errorDesc ?: @"<unknown>");
1439
+ }
1440
+ dispatch_semaphore_signal(semaphore);
1441
+ }];
1442
+
1443
+ [task resume];
1444
+ dispatch_time_t timeoutTime =
1445
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC));
1446
+ long waitResult = dispatch_semaphore_wait(semaphore, timeoutTime);
1447
+ [session finishTasksAndInvalidate];
1448
+
1449
+ // Check if we timed out waiting for the request
1450
+ if (waitResult != 0) {
1451
+ RJLogInfo(@"[RJ-HTTP] Request TIMED OUT waiting for response: %@ %@",
1452
+ request.HTTPMethod ?: @"<nil>", urlString);
1453
+ success = NO;
1454
+ }
1455
+
1456
+ RJLogInfo(
1457
+ @"[RJ-HTTP] %@ %@ -> status=%ld, success=%d, respBytes=%lu, error=%@",
1458
+ request.HTTPMethod ?: @"<nil>", urlString, (long)statusCode, success,
1459
+ (unsigned long)(responseData ? responseData.length : 0),
1460
+ errorDesc ?: @"none");
1461
+
1462
+ RJLogDebug(@"HTTP %@ %@ -> status=%ld ok=%@ respBytes=%lu",
1463
+ request.HTTPMethod ?: @"<nil>", RJRedactedURLForLogFromURL(url),
1464
+ (long)statusCode, success ? @"YES" : @"NO",
1465
+ (unsigned long)(responseData ? responseData.length : 0));
1466
+
1467
+ if (!success && statusCode == 401 && retryCount > 0) {
1468
+ RJLogDebug(@"Request failed with 401, attempting token refresh with "
1469
+ @"auto-register and retry...");
1470
+
1471
+ __block BOOL refreshSuccess = NO;
1472
+ dispatch_semaphore_t refreshSem = dispatch_semaphore_create(0);
1473
+
1474
+ [[RJDeviceAuthManager sharedManager]
1475
+ getUploadTokenWithAutoRegisterCompletion:^(
1476
+ BOOL tokenSuccess, NSString *token, NSInteger expiresIn,
1477
+ NSError *error) {
1478
+ refreshSuccess = tokenSuccess;
1479
+ dispatch_semaphore_signal(refreshSem);
1480
+ }];
1481
+
1482
+ dispatch_semaphore_wait(
1483
+ refreshSem,
1484
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(15.0 * NSEC_PER_SEC)));
1485
+
1486
+ if (refreshSuccess) {
1487
+ RJLogDebug(@"Token refresh successful, retrying request...");
1488
+ return [self sendJSONRequestTo:urlString
1489
+ method:method
1490
+ body:body
1491
+ timeoutSec:timeout
1492
+ retryCount:retryCount - 1
1493
+ responseJSON:responseJSON];
1494
+ } else {
1495
+ RJLogError(@"Token refresh failed, cannot retry request");
1496
+ }
1497
+ }
1498
+
1499
+ if (!success) {
1500
+ RJLogError(@"Request to %@ failed (status %ld)%@",
1501
+ RJRedactedURLForLogFromString(urlString), (long)statusCode,
1502
+ (errorDesc.length > 0)
1503
+ ? [NSString stringWithFormat:@" err=%@", errorDesc]
1504
+ : @"");
1505
+ return NO;
1506
+ }
1507
+
1508
+ if (responseJSON && responseData) {
1509
+ NSError *parseError = nil;
1510
+ NSDictionary *json = [NSJSONSerialization JSONObjectWithData:responseData
1511
+ options:0
1512
+ error:&parseError];
1513
+ if (!parseError && [json isKindOfClass:[NSDictionary class]]) {
1514
+ *responseJSON = json;
1515
+ }
1516
+ }
1517
+
1518
+ return YES;
1519
+ }
1520
+
1521
+ - (BOOL)sendJSONRequestTo:(NSString *)urlString
1522
+ method:(NSString *)method
1523
+ body:(NSDictionary *)body
1524
+ timeoutSec:(NSTimeInterval)timeout
1525
+ responseJSON:(NSDictionary *__autoreleasing *)responseJSON {
1526
+ return [self sendJSONRequestTo:urlString
1527
+ method:method
1528
+ body:body
1529
+ timeoutSec:timeout
1530
+ retryCount:1
1531
+ responseJSON:responseJSON];
1532
+ }
1533
+
1534
+ - (BOOL)uploadData:(NSData *)data
1535
+ toPresignedURL:(NSString *)urlString
1536
+ contentType:(NSString *)contentType {
1537
+
1538
+ NSURL *url = [NSURL URLWithString:urlString];
1539
+ if (!url) {
1540
+ RJLogError(@"Invalid presigned URL");
1541
+ return NO;
1542
+ }
1543
+
1544
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
1545
+ request.HTTPMethod = @"PUT";
1546
+ request.HTTPBody = data;
1547
+ request.timeoutInterval = RJNetworkResourceTimeout;
1548
+ [request setValue:contentType forHTTPHeaderField:@"Content-Type"];
1549
+ [request setValue:[NSString
1550
+ stringWithFormat:@"%lu", (unsigned long)data.length]
1551
+ forHTTPHeaderField:@"Content-Length"];
1552
+
1553
+ RJLogDebug(@"PUT %@ (bytes=%lu type=%@ timeout=%.1fs)",
1554
+ RJRedactedURLForLogFromURL(url), (unsigned long)data.length,
1555
+ contentType ?: @"<nil>", RJNetworkResourceTimeout);
1556
+
1557
+ __block BOOL success = NO;
1558
+ __block NSInteger statusCode = 0;
1559
+ NSTimeInterval start = [[NSDate date] timeIntervalSince1970];
1560
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
1561
+
1562
+ NSURLSessionConfiguration *config =
1563
+ [NSURLSessionConfiguration defaultSessionConfiguration];
1564
+ config.timeoutIntervalForRequest = RJNetworkResourceTimeout;
1565
+ config.timeoutIntervalForResource = RJNetworkResourceTimeout;
1566
+ NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
1567
+
1568
+ NSURLSessionDataTask *task = [session
1569
+ dataTaskWithRequest:request
1570
+ completionHandler:^(NSData *dataResp, NSURLResponse *response,
1571
+ NSError *error) {
1572
+ if (!error && [response isKindOfClass:[NSHTTPURLResponse class]]) {
1573
+ NSHTTPURLResponse *http = (NSHTTPURLResponse *)response;
1574
+ statusCode = http.statusCode;
1575
+ success = (http.statusCode >= 200 && http.statusCode < 300);
1576
+ if (!success) {
1577
+ RJLogError(@"Presigned upload failed with status %ld",
1578
+ (long)http.statusCode);
1579
+ }
1580
+ } else {
1581
+ RJLogError(@"Presigned upload error: %@",
1582
+ error.localizedDescription);
1583
+ }
1584
+ dispatch_semaphore_signal(semaphore);
1585
+ }];
1586
+
1587
+ [task resume];
1588
+ dispatch_time_t timeout = dispatch_time(
1589
+ DISPATCH_TIME_NOW, (int64_t)(RJNetworkResourceTimeout * NSEC_PER_SEC));
1590
+ dispatch_semaphore_wait(semaphore, timeout);
1591
+ [session finishTasksAndInvalidate];
1592
+
1593
+ NSTimeInterval elapsedMs =
1594
+ ([[NSDate date] timeIntervalSince1970] - start) * 1000.0;
1595
+ RJLogDebug(@"PUT %@ -> status=%ld ok=%@ (%.0fms)",
1596
+ RJRedactedURLForLogFromURL(url), (long)statusCode,
1597
+ success ? @"YES" : @"NO", elapsedMs);
1598
+
1599
+ return success;
1600
+ }
1601
+
1602
+ - (BOOL)presignForContentType:(NSString *)contentType
1603
+ batchNumber:(NSInteger)batchNumber
1604
+ sizeBytes:(NSUInteger)sizeBytes
1605
+ isKeyframe:(BOOL)isKeyframe
1606
+ result:(NSDictionary *__autoreleasing *)result {
1607
+
1608
+ if (![self ensureAttestation]) {
1609
+ RJLogError(@"Attestation failed; blocking presign");
1610
+ return NO;
1611
+ }
1612
+
1613
+ NSMutableDictionary *body = [NSMutableDictionary dictionary];
1614
+ body[@"batchNumber"] = @(batchNumber);
1615
+ body[@"contentType"] = contentType;
1616
+ body[@"sizeBytes"] = @(sizeBytes);
1617
+ body[@"userId"] = self.userId ?: @"anonymous";
1618
+ body[@"sessionId"] = self.sessionId ?: @"";
1619
+ body[@"sessionStartTime"] = @(self.sessionStartTime * 1000);
1620
+
1621
+ NSString *urlString =
1622
+ [NSString stringWithFormat:@"%@/api/ingest/presign", self.apiUrl];
1623
+
1624
+ RJLogInfo(
1625
+ @"[RJ-PRESIGN] Requesting presign for sessionId=%@, contentType=%@, "
1626
+ @"batch=%ld, sizeBytes=%lu, userId=%@, apiUrl=%@",
1627
+ self.sessionId ?: @"<nil>", contentType, (long)batchNumber,
1628
+ (unsigned long)sizeBytes, self.userId ?: @"anonymous",
1629
+ self.apiUrl ?: @"<nil>");
1630
+
1631
+ RJLogDebug(@"Presign %@ batch=%ld bytes=%lu keyframe=%@ -> %@", contentType,
1632
+ (long)batchNumber, (unsigned long)sizeBytes,
1633
+ isKeyframe ? @"YES" : @"NO",
1634
+ RJRedactedURLForLogFromString(urlString));
1635
+ NSDictionary *presignResponse = nil;
1636
+ BOOL ok = [self sendJSONRequestTo:urlString
1637
+ method:@"POST"
1638
+ body:body
1639
+ timeoutSec:RJNetworkRequestTimeout
1640
+ responseJSON:&presignResponse];
1641
+
1642
+ RJLogInfo(@"[RJ-PRESIGN] Presign result: ok=%d, sessionId=%@, hasResponse=%d",
1643
+ ok, self.sessionId ?: @"<nil>", presignResponse != nil);
1644
+
1645
+ if (ok && presignResponse) {
1646
+
1647
+ if ([presignResponse[@"skipUpload"] boolValue]) {
1648
+ RJLogDebug(@"Server indicated skip upload for %@ (recording disabled)",
1649
+ contentType);
1650
+ if (result) {
1651
+ *result = presignResponse;
1652
+ }
1653
+
1654
+ return YES;
1655
+ }
1656
+
1657
+ if (presignResponse[@"sessionId"]) {
1658
+ self.sessionId = presignResponse[@"sessionId"];
1659
+ }
1660
+ if (result) {
1661
+ *result = presignResponse;
1662
+ }
1663
+
1664
+ NSString *batchId = presignResponse[@"batchId"];
1665
+ if (batchId.length > 0) {
1666
+ RJLogDebug(@"Presign ok %@ batch=%ld batchId=%@ sessionId=%@",
1667
+ contentType, (long)batchNumber, batchId,
1668
+ self.sessionId ?: @"");
1669
+ }
1670
+ }
1671
+
1672
+ return ok;
1673
+ }
1674
+
1675
+ - (BOOL)ensureAttestation {
1676
+
1677
+ if (!self.deviceAuthManager) {
1678
+ self.deviceAuthManager = [RJDeviceAuthManager sharedManager];
1679
+ }
1680
+
1681
+ if ([self.deviceAuthManager hasValidUploadToken]) {
1682
+ return YES;
1683
+ }
1684
+
1685
+ NSString *credentialId = [self.deviceAuthManager deviceCredentialId];
1686
+ if (credentialId.length > 0) {
1687
+ RJLogDebug(@"Device registered, waiting for upload token");
1688
+ return YES;
1689
+ }
1690
+
1691
+ RJLogWarning(@"Device not yet registered for authentication");
1692
+ return YES;
1693
+ }
1694
+
1695
+ - (BOOL)completeBatchWithId:(NSString *)batchId
1696
+ actualSizeBytes:(NSUInteger)actualSize
1697
+ eventCount:(NSUInteger)eventCount
1698
+ frameCount:(NSUInteger)frameCount {
1699
+
1700
+ if (!batchId || batchId.length == 0)
1701
+ return NO;
1702
+
1703
+ NSString *urlString =
1704
+ [NSString stringWithFormat:@"%@/api/ingest/batch/complete", self.apiUrl];
1705
+ NSDictionary *body = @{
1706
+ @"batchId" : batchId,
1707
+ @"actualSizeBytes" : @(actualSize),
1708
+ @"eventCount" : @(eventCount),
1709
+ @"frameCount" : @(frameCount),
1710
+ @"userId" : self.userId ?: @"anonymous"
1711
+ };
1712
+
1713
+ RJLogDebug(@"Complete batch batchId=%@ bytes=%lu events=%lu frames=%lu -> %@",
1714
+ batchId, (unsigned long)actualSize, (unsigned long)eventCount,
1715
+ (unsigned long)frameCount,
1716
+ RJRedactedURLForLogFromString(urlString));
1717
+
1718
+ return [self sendJSONRequestTo:urlString
1719
+ method:@"POST"
1720
+ body:body
1721
+ timeoutSec:RJNetworkRequestTimeout
1722
+ responseJSON:nil];
1723
+ }
1724
+
1725
+ - (BOOL)endSessionSync {
1726
+ return [self endSessionSyncWithEndedAt:0 timeout:RJNetworkRequestTimeout];
1727
+ }
1728
+
1729
+ - (BOOL)endSessionSyncWithTimeout:(NSTimeInterval)timeout {
1730
+ return [self endSessionSyncWithEndedAt:0 timeout:timeout];
1731
+ }
1732
+
1733
+ - (BOOL)endSessionSyncWithEndedAt:(NSTimeInterval)endedAtOverride
1734
+ timeout:(NSTimeInterval)timeout {
1735
+ if (!self.sessionId || self.sessionId.length == 0)
1736
+ return YES;
1737
+
1738
+ NSString *urlString =
1739
+ [NSString stringWithFormat:@"%@/api/ingest/session/end", self.apiUrl];
1740
+
1741
+ NSNumber *endedAt;
1742
+ if (endedAtOverride > 0) {
1743
+ endedAt = @((NSUInteger)endedAtOverride);
1744
+ } else {
1745
+ endedAt = @((NSUInteger)([[NSDate date] timeIntervalSince1970] * 1000));
1746
+ }
1747
+
1748
+ NSMutableDictionary *body = [NSMutableDictionary dictionary];
1749
+ body[@"sessionId"] = self.sessionId;
1750
+ body[@"endedAt"] = endedAt;
1751
+
1752
+ RJLogInfo(@"[RJ-SESSION-END] Sending session/end: sessionId=%@, endedAt=%@, "
1753
+ @"totalBackgroundTimeMs=%.1f",
1754
+ self.sessionId, endedAt, self.totalBackgroundTimeMs);
1755
+
1756
+ if (self.totalBackgroundTimeMs > 0) {
1757
+ body[@"totalBackgroundTimeMs"] = @(self.totalBackgroundTimeMs);
1758
+ RJLogInfo(
1759
+ @"[RJ-SESSION-END] Including totalBackgroundTimeMs=%.1f in request",
1760
+ self.totalBackgroundTimeMs);
1761
+ } else {
1762
+ RJLogInfo(@"[RJ-SESSION-END] totalBackgroundTimeMs is 0, not including in "
1763
+ @"request");
1764
+ }
1765
+
1766
+ NSDictionary *telemetry = [[RJTelemetry sharedInstance] metricsAsDictionary];
1767
+ if (telemetry && telemetry.count > 0) {
1768
+ body[@"sdkTelemetry"] = telemetry;
1769
+ }
1770
+
1771
+ BOOL ok = [self sendJSONRequestTo:urlString
1772
+ method:@"POST"
1773
+ body:body
1774
+ timeoutSec:timeout
1775
+ responseJSON:nil];
1776
+
1777
+ if (ok) {
1778
+
1779
+ NSString *dir =
1780
+ [self.pendingRootPath stringByAppendingPathComponent:self.sessionId];
1781
+ NSArray<NSString *> *files =
1782
+ [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dir error:nil]
1783
+ ?: @[];
1784
+ NSArray<NSString *> *remaining =
1785
+ [files filteredArrayUsingPredicate:
1786
+ [NSPredicate predicateWithFormat:@"SELF ENDSWITH '.gz'"]];
1787
+ if (remaining.count == 0) {
1788
+ [self clearSessionRecovery:self.sessionId];
1789
+ }
1790
+ }
1791
+
1792
+ return ok;
1793
+ }
1794
+
1795
+ #pragma mark - Presign Flow (Events)
1796
+
1797
+ - (BOOL)uploadEventsSync:(NSArray<NSDictionary *> *)events
1798
+ isFinal:(BOOL)isFinal {
1799
+
1800
+ if (self.sessionId.length > 0) {
1801
+ if (![self flushPendingUploadsForSessionSync:self.sessionId]) {
1802
+ return NO;
1803
+ }
1804
+ }
1805
+
1806
+ if (events.count > 0) {
1807
+ if (![self uploadEventsBatchSync:events isFinal:isFinal]) {
1808
+ return NO;
1809
+ }
1810
+ }
1811
+
1812
+ if (isFinal) {
1813
+ if (![self endSessionSync]) {
1814
+ RJLogWarning(@"Session end call failed");
1815
+
1816
+ return NO;
1817
+ }
1818
+ }
1819
+
1820
+ return YES;
1821
+ }
1822
+
1823
+ - (BOOL)uploadEventsBatchSync:(NSArray<NSDictionary *> *)events
1824
+ isFinal:(BOOL)isFinal {
1825
+ self.eventBatchNumber += 1;
1826
+ NSInteger currentBatch = self.eventBatchNumber;
1827
+
1828
+ RJLogInfo(
1829
+ @"[RJ-UPLOAD] uploadEventsBatchSync starting: sessionId=%@, batch=%ld, "
1830
+ @"eventCount=%lu, isFinal=%d",
1831
+ self.sessionId ?: @"<nil>", (long)currentBatch,
1832
+ (unsigned long)events.count, isFinal);
1833
+
1834
+ NSDictionary *payload = [self buildEventPayloadWithEvents:events
1835
+ batchNumber:currentBatch
1836
+ isFinal:isFinal];
1837
+ if (!payload) {
1838
+ RJLogError(@"Failed to build events payload");
1839
+ [[RJTelemetry sharedInstance] recordEvent:RJTelemetryEventUploadFailure];
1840
+ return NO;
1841
+ }
1842
+
1843
+ NSError *jsonError = nil;
1844
+ NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload
1845
+ options:0
1846
+ error:&jsonError];
1847
+ if (jsonError || !jsonData) {
1848
+ RJLogError(@"Failed to serialize events payload: %@", jsonError);
1849
+ [[RJTelemetry sharedInstance] recordEvent:RJTelemetryEventUploadFailure];
1850
+ return NO;
1851
+ }
1852
+
1853
+ NSError *gzipError = nil;
1854
+ NSData *compressed = RJGzipData(jsonData, &gzipError);
1855
+ if (gzipError || !compressed) {
1856
+ RJLogError(@"Failed to gzip events payload: %@", gzipError);
1857
+ [[RJTelemetry sharedInstance] recordEvent:RJTelemetryEventUploadFailure];
1858
+ return NO;
1859
+ }
1860
+
1861
+ RJLogDebug(@"Events batch=%ld (sessionId=%@ isFinal=%@) jsonBytes=%lu "
1862
+ @"gzipBytes=%lu events=%lu",
1863
+ (long)currentBatch, self.sessionId ?: @"",
1864
+ isFinal ? @"YES" : @"NO", (unsigned long)jsonData.length,
1865
+ (unsigned long)compressed.length, (unsigned long)events.count);
1866
+
1867
+ [self persistPendingUploadWithContentType:@"events"
1868
+ batchNumber:currentBatch
1869
+ keyframe:NO
1870
+ gzipped:compressed
1871
+ eventCount:events.count
1872
+ frameCount:0];
1873
+ [self markSessionActiveForRecovery];
1874
+
1875
+ NSTimeInterval startTime = [[NSDate date] timeIntervalSince1970] * 1000;
1876
+
1877
+ NSDictionary *presign = nil;
1878
+ BOOL presignOk = [self presignForContentType:@"events"
1879
+ batchNumber:currentBatch
1880
+ sizeBytes:compressed.length
1881
+ isKeyframe:NO
1882
+ result:&presign];
1883
+ if (!presignOk || !presign) {
1884
+ [[RJTelemetry sharedInstance] recordEvent:RJTelemetryEventUploadFailure];
1885
+ return NO;
1886
+ }
1887
+
1888
+ NSString *uploadUrl = presign[@"presignedUrl"];
1889
+ NSString *batchId = presign[@"batchId"];
1890
+ if (![self uploadData:compressed
1891
+ toPresignedURL:uploadUrl
1892
+ contentType:@"application/gzip"]) {
1893
+ [[RJTelemetry sharedInstance] recordEvent:RJTelemetryEventUploadFailure];
1894
+ return NO;
1895
+ }
1896
+
1897
+ BOOL success = [self completeBatchWithId:batchId
1898
+ actualSizeBytes:compressed.length
1899
+ eventCount:events.count
1900
+ frameCount:0];
1901
+
1902
+ RJLogDebug(@"Events batch=%ld complete ok=%@", (long)currentBatch,
1903
+ success ? @"YES" : @"NO");
1904
+
1905
+ if (success) {
1906
+ NSString *dir = [self pendingSessionDir:self.sessionId];
1907
+ NSString *name = [self pendingFilenameForContentType:@"events"
1908
+ batchNumber:currentBatch
1909
+ keyframe:NO];
1910
+ NSString *path = [dir stringByAppendingPathComponent:name];
1911
+ [[NSFileManager defaultManager] removeItemAtPath:path error:nil];
1912
+ [[NSFileManager defaultManager]
1913
+ removeItemAtPath:[path stringByAppendingString:@".meta.json"]
1914
+ error:nil];
1915
+ }
1916
+
1917
+ NSTimeInterval endTime = [[NSDate date] timeIntervalSince1970] * 1000;
1918
+ NSTimeInterval durationMs = endTime - startTime;
1919
+
1920
+ [[RJTelemetry sharedInstance] recordUploadDuration:durationMs
1921
+ success:success
1922
+ byteCount:compressed.length];
1923
+
1924
+ return success;
1925
+ }
1926
+
1927
+ #pragma mark - Retry & Resilience
1928
+
1929
+ - (NSString *)failedUploadsPath {
1930
+ NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
1931
+ NSUserDomainMask, YES);
1932
+ NSString *cacheDir = paths.firstObject;
1933
+ return [cacheDir stringByAppendingPathComponent:@"rj_failed_uploads.plist"];
1934
+ }
1935
+
1936
+ static const NSInteger kCircuitBreakerThreshold = 5;
1937
+ static const NSTimeInterval kCircuitBreakerTimeout = 60.0;
1938
+ static const NSTimeInterval kMaxRetryDelay = 60.0;
1939
+
1940
+ - (void)persistPendingUploads {
1941
+ dispatch_async(self.uploadQueue, ^{
1942
+ @try {
1943
+ if (self.retryQueue.count == 0) {
1944
+ return;
1945
+ }
1946
+
1947
+ NSString *path = [self failedUploadsPath];
1948
+
1949
+ NSMutableArray *allPending = [NSMutableArray array];
1950
+ if ([[NSFileManager defaultManager] fileExistsAtPath:path]) {
1951
+ NSArray *existing = [NSArray arrayWithContentsOfFile:path];
1952
+ if (existing) {
1953
+ [allPending addObjectsFromArray:existing];
1954
+ }
1955
+ }
1956
+
1957
+ [allPending addObjectsFromArray:self.retryQueue];
1958
+
1959
+ if (allPending.count > 100) {
1960
+ allPending = [[allPending
1961
+ subarrayWithRange:NSMakeRange(allPending.count - 100, 100)]
1962
+ mutableCopy];
1963
+ }
1964
+
1965
+ BOOL success = [allPending writeToFile:path atomically:YES];
1966
+ if (success) {
1967
+ RJLogDebug(@"Persisted %lu failed uploads to disk",
1968
+ (unsigned long)allPending.count);
1969
+ [self.retryQueue removeAllObjects];
1970
+ } else {
1971
+ RJLogWarning(@"Failed to persist uploads to disk");
1972
+ }
1973
+ } @catch (NSException *exception) {
1974
+ RJLogWarning(@"Persist uploads exception: %@", exception);
1975
+ }
1976
+ });
1977
+ }
1978
+
1979
+ - (void)loadAndRetryPersistedUploads {
1980
+ dispatch_async(self.uploadQueue, ^{
1981
+ @try {
1982
+ NSString *path = [self failedUploadsPath];
1983
+
1984
+ if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
1985
+ return;
1986
+ }
1987
+
1988
+ NSArray *persisted = [NSArray arrayWithContentsOfFile:path];
1989
+ if (!persisted || persisted.count == 0) {
1990
+ return;
1991
+ }
1992
+
1993
+ RJLogDebug(@"Found %lu persisted failed uploads, queuing for retry",
1994
+ (unsigned long)persisted.count);
1995
+
1996
+ [[NSFileManager defaultManager] removeItemAtPath:path error:nil];
1997
+
1998
+ [self.retryQueue addObjectsFromArray:persisted];
1999
+
2000
+ [self scheduleRetryIfNeeded];
2001
+ } @catch (NSException *exception) {
2002
+ RJLogWarning(@"Load persisted uploads exception: %@", exception);
2003
+ }
2004
+ });
2005
+ }
2006
+
2007
+ - (void)addToRetryQueueWithEvents:(NSArray<NSDictionary *> *)events {
2008
+ if (self.isShuttingDown)
2009
+ return;
2010
+
2011
+ dispatch_async(self.uploadQueue, ^{
2012
+ @try {
2013
+ NSDictionary *retryItem = @{
2014
+ @"events" : events ?: @[],
2015
+ @"timestamp" : @([[NSDate date] timeIntervalSince1970]),
2016
+ @"attemptCount" : @0
2017
+ };
2018
+
2019
+ [self.retryQueue addObject:retryItem];
2020
+ RJLogDebug(@"Added batch to retry queue (queue size: %lu)",
2021
+ (unsigned long)self.retryQueue.count);
2022
+
2023
+ [self scheduleRetryIfNeeded];
2024
+ } @catch (NSException *exception) {
2025
+ RJLogWarning(@"Add to retry queue exception: %@", exception);
2026
+ }
2027
+ });
2028
+ }
2029
+
2030
+ - (void)scheduleRetryIfNeeded {
2031
+
2032
+ if (self.isRetryScheduled || self.retryQueue.count == 0 ||
2033
+ self.isShuttingDown) {
2034
+ return;
2035
+ }
2036
+
2037
+ if (self.isCircuitOpen) {
2038
+ NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
2039
+ if (now - self.circuitOpenedTime < kCircuitBreakerTimeout) {
2040
+ RJLogDebug(@"Circuit breaker open, waiting %.0fs before retry",
2041
+ kCircuitBreakerTimeout - (now - self.circuitOpenedTime));
2042
+ return;
2043
+ }
2044
+
2045
+ RJLogDebug(@"Circuit breaker entering half-open state");
2046
+ self.isCircuitOpen = NO;
2047
+ }
2048
+
2049
+ self.isRetryScheduled = YES;
2050
+
2051
+ NSTimeInterval delay =
2052
+ MIN(pow(2.0, self.consecutiveFailureCount), kMaxRetryDelay);
2053
+
2054
+ RJLogDebug(@"Scheduling retry in %.1fs (consecutive failures: %ld)", delay,
2055
+ (long)self.consecutiveFailureCount);
2056
+
2057
+ __weak typeof(self) weakSelf = self;
2058
+ dispatch_after(
2059
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)),
2060
+ self.uploadQueue, ^{
2061
+ __strong typeof(weakSelf) strongSelf = weakSelf;
2062
+ if (!strongSelf || strongSelf.isShuttingDown)
2063
+ return;
2064
+
2065
+ strongSelf.isRetryScheduled = NO;
2066
+ [strongSelf processRetryQueue];
2067
+ });
2068
+ }
2069
+
2070
+ - (void)processRetryQueue {
2071
+
2072
+ if (self.retryQueue.count == 0 || self.isUploading || self.isShuttingDown) {
2073
+ return;
2074
+ }
2075
+
2076
+ NSDictionary *item = self.retryQueue.firstObject;
2077
+ NSArray *events = item[@"events"];
2078
+ NSInteger attemptCount = [item[@"attemptCount"] integerValue];
2079
+
2080
+ [self.retryQueue removeObjectAtIndex:0];
2081
+
2082
+ RJLogDebug(@"Retrying batch (attempt %ld, remaining: %lu)",
2083
+ (long)(attemptCount + 1), (unsigned long)self.retryQueue.count);
2084
+
2085
+ self.isUploading = YES;
2086
+
2087
+ __weak typeof(self) weakSelf = self;
2088
+ dispatch_async(self.uploadQueue, ^{
2089
+ __strong typeof(weakSelf) strongSelf = weakSelf;
2090
+ if (!strongSelf)
2091
+ return;
2092
+
2093
+ BOOL success = NO;
2094
+ @try {
2095
+ success = [strongSelf uploadEventsSync:events isFinal:NO];
2096
+ } @catch (NSException *exception) {
2097
+ RJLogWarning(@"Retry upload exception: %@", exception);
2098
+ success = NO;
2099
+ }
2100
+
2101
+ dispatch_async(dispatch_get_main_queue(), ^{
2102
+ strongSelf.isUploading = NO;
2103
+
2104
+ if (success) {
2105
+
2106
+ strongSelf.consecutiveFailureCount = 0;
2107
+ strongSelf.isCircuitOpen = NO;
2108
+ RJLogDebug(@"Retry upload succeeded");
2109
+
2110
+ if (strongSelf.retryQueue.count > 0) {
2111
+ [strongSelf scheduleRetryIfNeeded];
2112
+ }
2113
+ } else {
2114
+
2115
+ strongSelf.consecutiveFailureCount++;
2116
+
2117
+ if (strongSelf.consecutiveFailureCount >= kCircuitBreakerThreshold) {
2118
+ strongSelf.isCircuitOpen = YES;
2119
+ strongSelf.circuitOpenedTime = [[NSDate date] timeIntervalSince1970];
2120
+ RJLogWarning(@"Circuit breaker opened after %ld failures",
2121
+ (long)strongSelf.consecutiveFailureCount);
2122
+ }
2123
+
2124
+ if (attemptCount < 5) {
2125
+ NSMutableDictionary *updatedItem = [item mutableCopy];
2126
+ updatedItem[@"attemptCount"] = @(attemptCount + 1);
2127
+ [strongSelf.retryQueue addObject:updatedItem];
2128
+ RJLogDebug(@"Re-queued failed batch (attempt %ld)",
2129
+ (long)(attemptCount + 1));
2130
+ } else {
2131
+ RJLogWarning(@"Batch exceeded max retries, discarding");
2132
+ }
2133
+
2134
+ [strongSelf scheduleRetryIfNeeded];
2135
+ }
2136
+ });
2137
+ });
2138
+ }
2139
+
2140
+ - (void)recordUploadSuccess {
2141
+ self.consecutiveFailureCount = 0;
2142
+ [[RJTelemetry sharedInstance] recordEvent:RJTelemetryEventUploadSuccess];
2143
+ if (self.isCircuitOpen) {
2144
+ RJLogDebug(@"Upload succeeded, closing circuit breaker");
2145
+ self.isCircuitOpen = NO;
2146
+ [[RJTelemetry sharedInstance]
2147
+ recordEvent:RJTelemetryEventCircuitBreakerClose];
2148
+ }
2149
+ }
2150
+
2151
+ - (void)recordUploadFailure {
2152
+ self.consecutiveFailureCount++;
2153
+ [[RJTelemetry sharedInstance] recordEvent:RJTelemetryEventUploadFailure];
2154
+ if (self.consecutiveFailureCount >= kCircuitBreakerThreshold &&
2155
+ !self.isCircuitOpen) {
2156
+ self.isCircuitOpen = YES;
2157
+ self.circuitOpenedTime = [[NSDate date] timeIntervalSince1970];
2158
+ [[RJTelemetry sharedInstance]
2159
+ recordEvent:RJTelemetryEventCircuitBreakerOpen];
2160
+ RJLogWarning(@"Circuit breaker opened after %ld consecutive failures",
2161
+ (long)self.consecutiveFailureCount);
2162
+ }
2163
+ }
2164
+
2165
+ #pragma mark - Background Task Management
2166
+
2167
+ - (UIBackgroundTaskIdentifier)beginBackgroundTaskWithName:(NSString *)name {
2168
+ __block UIBackgroundTaskIdentifier taskId = UIBackgroundTaskInvalid;
2169
+
2170
+ __weak typeof(self) weakSelf = self;
2171
+ taskId = [[UIApplication sharedApplication]
2172
+ beginBackgroundTaskWithName:name
2173
+ expirationHandler:^{
2174
+ __strong typeof(weakSelf) strongSelf = weakSelf;
2175
+ RJLogWarning(@"Background task '%@' expired - saving state",
2176
+ name);
2177
+
2178
+ if (strongSelf) {
2179
+ [strongSelf persistPendingUploads];
2180
+ }
2181
+
2182
+ [[UIApplication sharedApplication] endBackgroundTask:taskId];
2183
+ }];
2184
+
2185
+ return taskId;
2186
+ }
2187
+
2188
+ - (void)endBackgroundTask:(UIBackgroundTaskIdentifier)taskId {
2189
+ if (taskId != UIBackgroundTaskInvalid) {
2190
+ [[UIApplication sharedApplication] endBackgroundTask:taskId];
2191
+ }
2192
+ }
2193
+
2194
+ #pragma mark - State Reset
2195
+
2196
+ - (void)resetForNewSession {
2197
+ @try {
2198
+
2199
+ if (self.activeTask) {
2200
+ [self.activeTask cancel];
2201
+ self.activeTask = nil;
2202
+ }
2203
+
2204
+ self.sessionId = nil;
2205
+ self.userId = nil;
2206
+ self.sessionStartTime = 0;
2207
+ self.batchNumber = 0;
2208
+ self.eventBatchNumber = 0;
2209
+ self.lastUploadTime = 0;
2210
+ self.isUploading = NO;
2211
+ } @catch (NSException *exception) {
2212
+ RJLogWarning(@"Session reset failed: %@", exception);
2213
+ }
2214
+ }
2215
+
2216
+ - (void)shutdown {
2217
+ self.isShuttingDown = YES;
2218
+
2219
+ if (self.activeTask) {
2220
+ [self.activeTask cancel];
2221
+ self.activeTask = nil;
2222
+ }
2223
+
2224
+ [self stopBatchUploadTimer];
2225
+ }
2226
+
2227
+ #pragma mark - Replay Promotion
2228
+
2229
+ - (void)evaluateReplayPromotionWithMetrics:(NSDictionary *)metrics
2230
+ completion:
2231
+ (void (^)(BOOL promoted,
2232
+ NSString *reason))completion {
2233
+ if (!self.sessionId || self.sessionId.length == 0) {
2234
+ if (completion) {
2235
+ completion(NO, @"no_session");
2236
+ }
2237
+ return;
2238
+ }
2239
+
2240
+ NSMutableDictionary *body = [NSMutableDictionary dictionary];
2241
+ body[@"sessionId"] = self.sessionId;
2242
+ body[@"metrics"] = metrics ?: @{};
2243
+
2244
+ NSString *urlString =
2245
+ [NSString stringWithFormat:@"%@/api/ingest/replay/evaluate", self.apiUrl];
2246
+
2247
+ RJLogDebug(@"Evaluating replay promotion for session: %@", self.sessionId);
2248
+
2249
+ __weak typeof(self) weakSelf = self;
2250
+ dispatch_async(self.uploadQueue, ^{
2251
+ __strong typeof(weakSelf) strongSelf = weakSelf;
2252
+ if (!strongSelf) {
2253
+ if (completion) {
2254
+ dispatch_async(dispatch_get_main_queue(), ^{
2255
+ completion(NO, @"deallocated");
2256
+ });
2257
+ }
2258
+ return;
2259
+ }
2260
+
2261
+ NSDictionary *response = nil;
2262
+ BOOL ok = [strongSelf sendJSONRequestTo:urlString
2263
+ method:@"POST"
2264
+ body:body
2265
+ timeoutSec:RJNetworkRequestTimeout
2266
+ responseJSON:&response];
2267
+
2268
+ dispatch_async(dispatch_get_main_queue(), ^{
2269
+ if (ok && response) {
2270
+ BOOL promoted = [response[@"promoted"] boolValue];
2271
+ NSString *reason = response[@"reason"] ?: @"unknown";
2272
+
2273
+ strongSelf.isReplayPromoted = promoted;
2274
+
2275
+ if (promoted) {
2276
+ RJLogDebug(@"Session promoted for replay upload (reason: %@)",
2277
+ reason);
2278
+ } else {
2279
+ RJLogDebug(@"Session not promoted for replay (reason: %@)", reason);
2280
+ }
2281
+
2282
+ if (completion) {
2283
+ completion(promoted, reason);
2284
+ }
2285
+ } else {
2286
+ RJLogWarning(@"Replay promotion evaluation failed");
2287
+ strongSelf.isReplayPromoted = NO;
2288
+ if (completion) {
2289
+ completion(NO, @"request_failed");
2290
+ }
2291
+ }
2292
+ });
2293
+ });
2294
+ }
2295
+
2296
+ @end