@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.
- package/android/build.gradle.kts +135 -0
- package/android/consumer-rules.pro +10 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
- package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
- package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
- package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
- package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
- package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
- package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
- package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
- package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
- package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
- package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
- package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
- package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
- package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
- package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
- package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
- package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
- package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
- package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
- package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
- package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
- package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
- package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
- package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
- package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
- package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
- package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
- package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
- package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
- package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Capture/RJANRHandler.h +42 -0
- package/ios/Capture/RJANRHandler.m +328 -0
- package/ios/Capture/RJCaptureEngine.h +275 -0
- package/ios/Capture/RJCaptureEngine.m +2062 -0
- package/ios/Capture/RJCaptureHeuristics.h +80 -0
- package/ios/Capture/RJCaptureHeuristics.m +903 -0
- package/ios/Capture/RJCrashHandler.h +46 -0
- package/ios/Capture/RJCrashHandler.m +313 -0
- package/ios/Capture/RJMotionEvent.h +183 -0
- package/ios/Capture/RJMotionEvent.m +183 -0
- package/ios/Capture/RJPerformanceManager.h +100 -0
- package/ios/Capture/RJPerformanceManager.m +373 -0
- package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
- package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
- package/ios/Capture/RJSegmentUploader.h +146 -0
- package/ios/Capture/RJSegmentUploader.m +778 -0
- package/ios/Capture/RJVideoEncoder.h +247 -0
- package/ios/Capture/RJVideoEncoder.m +1036 -0
- package/ios/Capture/RJViewControllerTracker.h +73 -0
- package/ios/Capture/RJViewControllerTracker.m +508 -0
- package/ios/Capture/RJViewHierarchyScanner.h +215 -0
- package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
- package/ios/Capture/RJViewSerializer.h +119 -0
- package/ios/Capture/RJViewSerializer.m +498 -0
- package/ios/Core/RJConstants.h +124 -0
- package/ios/Core/RJConstants.m +88 -0
- package/ios/Core/RJLifecycleManager.h +85 -0
- package/ios/Core/RJLifecycleManager.m +308 -0
- package/ios/Core/RJLogger.h +61 -0
- package/ios/Core/RJLogger.m +211 -0
- package/ios/Core/RJTypes.h +176 -0
- package/ios/Core/RJTypes.m +66 -0
- package/ios/Core/Rejourney.h +64 -0
- package/ios/Core/Rejourney.mm +2495 -0
- package/ios/Network/RJDeviceAuthManager.h +94 -0
- package/ios/Network/RJDeviceAuthManager.m +967 -0
- package/ios/Network/RJNetworkMonitor.h +68 -0
- package/ios/Network/RJNetworkMonitor.m +267 -0
- package/ios/Network/RJRetryManager.h +73 -0
- package/ios/Network/RJRetryManager.m +325 -0
- package/ios/Network/RJUploadManager.h +267 -0
- package/ios/Network/RJUploadManager.m +2296 -0
- package/ios/Privacy/RJPrivacyMask.h +163 -0
- package/ios/Privacy/RJPrivacyMask.m +922 -0
- package/ios/Rejourney.h +63 -0
- package/ios/Touch/RJGestureClassifier.h +130 -0
- package/ios/Touch/RJGestureClassifier.m +333 -0
- package/ios/Touch/RJTouchInterceptor.h +169 -0
- package/ios/Touch/RJTouchInterceptor.m +772 -0
- package/ios/Utils/RJEventBuffer.h +112 -0
- package/ios/Utils/RJEventBuffer.m +358 -0
- package/ios/Utils/RJGzipUtils.h +33 -0
- package/ios/Utils/RJGzipUtils.m +89 -0
- package/ios/Utils/RJKeychainManager.h +48 -0
- package/ios/Utils/RJKeychainManager.m +111 -0
- package/ios/Utils/RJPerfTiming.h +209 -0
- package/ios/Utils/RJPerfTiming.m +264 -0
- package/ios/Utils/RJTelemetry.h +92 -0
- package/ios/Utils/RJTelemetry.m +320 -0
- package/ios/Utils/RJWindowUtils.h +66 -0
- package/ios/Utils/RJWindowUtils.m +133 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +79 -0
- package/lib/commonjs/index.js +1381 -0
- package/lib/commonjs/sdk/autoTracking.js +1259 -0
- package/lib/commonjs/sdk/constants.js +151 -0
- package/lib/commonjs/sdk/errorTracking.js +199 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +204 -0
- package/lib/commonjs/sdk/navigation.js +151 -0
- package/lib/commonjs/sdk/networkInterceptor.js +412 -0
- package/lib/commonjs/sdk/utils.js +363 -0
- package/lib/commonjs/types/expo-router.d.js +2 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/module/NativeRejourney.js +38 -0
- package/lib/module/components/Mask.js +72 -0
- package/lib/module/index.js +1284 -0
- package/lib/module/sdk/autoTracking.js +1233 -0
- package/lib/module/sdk/constants.js +145 -0
- package/lib/module/sdk/errorTracking.js +189 -0
- package/lib/module/sdk/index.js +12 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +143 -0
- package/lib/module/sdk/networkInterceptor.js +401 -0
- package/lib/module/sdk/utils.js +342 -0
- package/lib/module/types/expo-router.d.js +2 -0
- package/lib/module/types/index.js +2 -0
- package/lib/typescript/NativeRejourney.d.ts +147 -0
- package/lib/typescript/components/Mask.d.ts +39 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +204 -0
- package/lib/typescript/sdk/constants.d.ts +120 -0
- package/lib/typescript/sdk/errorTracking.d.ts +32 -0
- package/lib/typescript/sdk/index.d.ts +9 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
- package/lib/typescript/sdk/navigation.d.ts +33 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
- package/lib/typescript/sdk/utils.d.ts +148 -0
- package/lib/typescript/types/index.d.ts +624 -0
- package/package.json +102 -0
- package/rejourney.podspec +21 -0
- package/src/NativeRejourney.ts +165 -0
- package/src/components/Mask.tsx +80 -0
- package/src/index.ts +1459 -0
- package/src/sdk/autoTracking.ts +1373 -0
- package/src/sdk/constants.ts +134 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +11 -0
- package/src/sdk/metricsTracking.ts +232 -0
- package/src/sdk/navigation.ts +157 -0
- package/src/sdk/networkInterceptor.ts +440 -0
- package/src/sdk/utils.ts +369 -0
- package/src/types/expo-router.d.ts +7 -0
- 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
|