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