@rejourneyco/react-native 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +29 -0
  2. package/android/build.gradle.kts +135 -0
  3. package/android/consumer-rules.pro +10 -0
  4. package/android/proguard-rules.pro +1 -0
  5. package/android/src/main/AndroidManifest.xml +15 -0
  6. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
  7. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
  8. package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
  9. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
  10. package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
  11. package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
  12. package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
  13. package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
  14. package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
  15. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
  16. package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
  17. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
  18. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
  19. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
  20. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
  21. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
  22. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
  23. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
  24. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
  25. package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
  26. package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
  27. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
  28. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  29. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
  30. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  31. package/ios/Engine/DeviceRegistrar.swift +288 -0
  32. package/ios/Engine/DiagnosticLog.swift +387 -0
  33. package/ios/Engine/RejourneyImpl.swift +719 -0
  34. package/ios/Recording/AnrSentinel.swift +142 -0
  35. package/ios/Recording/EventBuffer.swift +326 -0
  36. package/ios/Recording/InteractionRecorder.swift +428 -0
  37. package/ios/Recording/ReplayOrchestrator.swift +624 -0
  38. package/ios/Recording/SegmentDispatcher.swift +492 -0
  39. package/ios/Recording/StabilityMonitor.swift +223 -0
  40. package/ios/Recording/TelemetryPipeline.swift +547 -0
  41. package/ios/Recording/ViewHierarchyScanner.swift +156 -0
  42. package/ios/Recording/VisualCapture.swift +675 -0
  43. package/ios/Rejourney.h +38 -0
  44. package/ios/Rejourney.mm +375 -0
  45. package/ios/Utility/DataCompression.swift +55 -0
  46. package/ios/Utility/ImageBlur.swift +89 -0
  47. package/ios/Utility/RuntimeMethodSwap.swift +41 -0
  48. package/ios/Utility/ViewIdentifier.swift +37 -0
  49. package/lib/commonjs/NativeRejourney.js +40 -0
  50. package/lib/commonjs/components/Mask.js +88 -0
  51. package/lib/commonjs/index.js +1443 -0
  52. package/lib/commonjs/sdk/autoTracking.js +1087 -0
  53. package/lib/commonjs/sdk/constants.js +166 -0
  54. package/lib/commonjs/sdk/errorTracking.js +187 -0
  55. package/lib/commonjs/sdk/index.js +50 -0
  56. package/lib/commonjs/sdk/metricsTracking.js +205 -0
  57. package/lib/commonjs/sdk/navigation.js +128 -0
  58. package/lib/commonjs/sdk/networkInterceptor.js +375 -0
  59. package/lib/commonjs/sdk/utils.js +433 -0
  60. package/lib/commonjs/sdk/version.js +13 -0
  61. package/lib/commonjs/types/expo-router.d.js +2 -0
  62. package/lib/commonjs/types/index.js +2 -0
  63. package/lib/module/NativeRejourney.js +38 -0
  64. package/lib/module/components/Mask.js +83 -0
  65. package/lib/module/index.js +1341 -0
  66. package/lib/module/sdk/autoTracking.js +1059 -0
  67. package/lib/module/sdk/constants.js +154 -0
  68. package/lib/module/sdk/errorTracking.js +177 -0
  69. package/lib/module/sdk/index.js +26 -0
  70. package/lib/module/sdk/metricsTracking.js +187 -0
  71. package/lib/module/sdk/navigation.js +120 -0
  72. package/lib/module/sdk/networkInterceptor.js +364 -0
  73. package/lib/module/sdk/utils.js +412 -0
  74. package/lib/module/sdk/version.js +7 -0
  75. package/lib/module/types/expo-router.d.js +2 -0
  76. package/lib/module/types/index.js +2 -0
  77. package/lib/typescript/NativeRejourney.d.ts +160 -0
  78. package/lib/typescript/components/Mask.d.ts +54 -0
  79. package/lib/typescript/index.d.ts +117 -0
  80. package/lib/typescript/sdk/autoTracking.d.ts +226 -0
  81. package/lib/typescript/sdk/constants.d.ts +138 -0
  82. package/lib/typescript/sdk/errorTracking.d.ts +47 -0
  83. package/lib/typescript/sdk/index.d.ts +24 -0
  84. package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
  85. package/lib/typescript/sdk/navigation.d.ts +48 -0
  86. package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
  87. package/lib/typescript/sdk/utils.d.ts +193 -0
  88. package/lib/typescript/sdk/version.d.ts +6 -0
  89. package/lib/typescript/types/index.d.ts +618 -0
  90. package/package.json +122 -0
  91. package/rejourney.podspec +23 -0
  92. package/src/NativeRejourney.ts +185 -0
  93. package/src/components/Mask.tsx +93 -0
  94. package/src/index.ts +1555 -0
  95. package/src/sdk/autoTracking.ts +1245 -0
  96. package/src/sdk/constants.ts +155 -0
  97. package/src/sdk/errorTracking.ts +231 -0
  98. package/src/sdk/index.ts +25 -0
  99. package/src/sdk/metricsTracking.ts +227 -0
  100. package/src/sdk/navigation.ts +152 -0
  101. package/src/sdk/networkInterceptor.ts +423 -0
  102. package/src/sdk/utils.ts +442 -0
  103. package/src/sdk/version.ts +6 -0
  104. package/src/types/expo-router.d.ts +7 -0
  105. package/src/types/index.ts +709 -0
@@ -0,0 +1,624 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import UIKit
18
+ import Network
19
+ import QuartzCore
20
+
21
+ @objc(ReplayOrchestrator)
22
+ public final class ReplayOrchestrator: NSObject {
23
+
24
+ @objc public static let shared = ReplayOrchestrator()
25
+
26
+ @objc public var apiToken: String?
27
+ @objc public var replayId: String?
28
+ @objc public var replayStartMs: UInt64 = 0
29
+ @objc public var deferredUploadMode = false
30
+ @objc public var frameBundleSize: Int = 5
31
+
32
+ public var serverEndpoint: String {
33
+ get { TelemetryPipeline.shared.endpoint }
34
+ set {
35
+ TelemetryPipeline.shared.endpoint = newValue
36
+ SegmentDispatcher.shared.endpoint = newValue
37
+ DeviceRegistrar.shared.endpoint = newValue
38
+ }
39
+ }
40
+
41
+ @objc public var snapshotInterval: Double = 0.33
42
+ @objc public var compressionLevel: Double = 0.5
43
+ @objc public var visualCaptureEnabled: Bool = true
44
+ @objc public var interactionCaptureEnabled: Bool = true
45
+ @objc public var faultTrackingEnabled: Bool = true
46
+ @objc public var responsivenessCaptureEnabled: Bool = true
47
+ @objc public var consoleCaptureEnabled: Bool = true
48
+ @objc public var wifiRequired: Bool = false
49
+ @objc public var hierarchyCaptureEnabled: Bool = true
50
+ @objc public var hierarchyCaptureInterval: Double = 2.0
51
+ @objc public private(set) var currentScreenName: String?
52
+
53
+ // Remote config from backend (set via setRemoteConfig before session start)
54
+ @objc public private(set) var remoteRejourneyEnabled: Bool = true
55
+ @objc public private(set) var remoteRecordingEnabled: Bool = true
56
+ @objc public private(set) var remoteSampleRate: Int = 100
57
+ @objc public private(set) var remoteMaxRecordingMinutes: Int = 10
58
+
59
+ private var _netMonitor: NWPathMonitor?
60
+ private var _netReady = false
61
+ private var _live = false
62
+
63
+ // Network state tracking
64
+ @objc public private(set) var currentNetworkType: String = "unknown"
65
+ @objc public private(set) var currentCellularGeneration: String = "unknown"
66
+ @objc public private(set) var networkIsConstrained: Bool = false
67
+ @objc public private(set) var networkIsExpensive: Bool = false
68
+
69
+ // App startup tracking - use actual process start time from kernel
70
+ private static var processStartTime: TimeInterval = {
71
+ // Get the actual process start time from the kernel
72
+ var kinfo = kinfo_proc()
73
+ var size = MemoryLayout<kinfo_proc>.stride
74
+ var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
75
+
76
+ if sysctl(&mib, UInt32(mib.count), &kinfo, &size, nil, 0) == 0 {
77
+ let startSec = kinfo.kp_proc.p_starttime.tv_sec
78
+ let startUsec = kinfo.kp_proc.p_starttime.tv_usec
79
+ return TimeInterval(startSec) + TimeInterval(startUsec) / 1_000_000.0
80
+ }
81
+ // Fallback to current time if sysctl fails
82
+ return Date().timeIntervalSince1970
83
+ }()
84
+
85
+ private var _crashCount = 0
86
+ private var _freezeCount = 0
87
+ private var _errorCount = 0
88
+ private var _tapCount = 0
89
+ private var _scrollCount = 0
90
+ private var _gestureCount = 0
91
+ private var _rageCount = 0
92
+ private var _deadTapCount = 0
93
+ private var _visitedScreens: [String] = []
94
+ private var _bgTimeMs: UInt64 = 0
95
+ private var _bgStartMs: UInt64?
96
+ private var _finalized = false
97
+ private var _hierarchyTimer: Timer?
98
+ private var _lastHierarchyHash: String?
99
+ private var _durationLimitTimer: DispatchWorkItem?
100
+
101
+ private override init() {
102
+ super.init()
103
+ }
104
+
105
+ /// Fast session start using existing credentials - skips credential fetch for faster restart
106
+ @objc public func beginReplayFast(apiToken: String, serverEndpoint: String, credential: String, captureSettings: [String: Any]? = nil) {
107
+ let perf = PerformanceSnapshot.capture()
108
+ DiagnosticLog.debugSessionCreate(phase: "ORCHESTRATOR_FAST_INIT", details: "beginReplayFast with existing credential", perf: perf)
109
+
110
+ self.apiToken = apiToken
111
+ self.serverEndpoint = serverEndpoint
112
+ _applySettings(captureSettings)
113
+
114
+ // Set credentials AND endpoint directly without network fetch
115
+ TelemetryPipeline.shared.apiToken = apiToken
116
+ TelemetryPipeline.shared.credential = credential
117
+ TelemetryPipeline.shared.endpoint = serverEndpoint
118
+ SegmentDispatcher.shared.apiToken = apiToken
119
+ SegmentDispatcher.shared.credential = credential
120
+ SegmentDispatcher.shared.endpoint = serverEndpoint
121
+
122
+ // Skip network monitoring, assume network is available since we just came from background
123
+ DispatchQueue.main.async { [weak self] in
124
+ self?._beginRecording(token: apiToken)
125
+ }
126
+ }
127
+
128
+ @objc public func beginReplay(apiToken: String, serverEndpoint: String, captureSettings: [String: Any]? = nil) {
129
+ let perf = PerformanceSnapshot.capture()
130
+ DiagnosticLog.debugSessionCreate(phase: "ORCHESTRATOR_INIT", details: "beginReplay", perf: perf)
131
+
132
+ self.apiToken = apiToken
133
+ self.serverEndpoint = serverEndpoint
134
+ _applySettings(captureSettings)
135
+
136
+ DiagnosticLog.debugSessionCreate(phase: "CREDENTIAL_START", details: "Requesting device credential")
137
+
138
+ DeviceRegistrar.shared.obtainCredential(apiToken: apiToken) { [weak self] ok, cred in
139
+ guard let self, ok else {
140
+ DiagnosticLog.debugSessionCreate(phase: "CREDENTIAL_FAIL", details: "Failed")
141
+ return
142
+ }
143
+
144
+ TelemetryPipeline.shared.apiToken = apiToken
145
+ TelemetryPipeline.shared.credential = cred
146
+ SegmentDispatcher.shared.apiToken = apiToken
147
+ SegmentDispatcher.shared.credential = cred
148
+
149
+ self._monitorNetwork(token: apiToken)
150
+ }
151
+ }
152
+
153
+ @objc public func beginDeferredReplay(apiToken: String, serverEndpoint: String, captureSettings: [String: Any]? = nil) {
154
+ self.apiToken = apiToken
155
+ self.serverEndpoint = serverEndpoint
156
+ deferredUploadMode = true
157
+
158
+ _applySettings(captureSettings)
159
+
160
+ DeviceRegistrar.shared.obtainCredential(apiToken: apiToken) { [weak self] ok, cred in
161
+ guard let self, ok else { return }
162
+ TelemetryPipeline.shared.apiToken = apiToken
163
+ TelemetryPipeline.shared.credential = cred
164
+ SegmentDispatcher.shared.apiToken = apiToken
165
+ SegmentDispatcher.shared.credential = cred
166
+ }
167
+
168
+ _initSession()
169
+ TelemetryPipeline.shared.activateDeferredMode()
170
+
171
+ let renderCfg = _computeRender(fps: 3, tier: "standard")
172
+
173
+ if visualCaptureEnabled {
174
+ VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
175
+ VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs)
176
+ VisualCapture.shared.activateDeferredMode()
177
+ }
178
+
179
+ if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
180
+ if faultTrackingEnabled { FaultTracker.shared.activate() }
181
+
182
+ _live = true
183
+ }
184
+
185
+ @objc public func commitDeferredReplay() {
186
+ deferredUploadMode = false
187
+ TelemetryPipeline.shared.commitDeferredData()
188
+ VisualCapture.shared.commitDeferredData()
189
+ TelemetryPipeline.shared.activate()
190
+ }
191
+
192
+ @objc public func endReplay() {
193
+ endReplay(completion: nil)
194
+ }
195
+
196
+ @objc public func endReplay(completion: ((Bool, Bool) -> Void)?) {
197
+ guard _live else {
198
+ completion?(false, false)
199
+ return
200
+ }
201
+ _live = false
202
+
203
+ let sid = replayId ?? ""
204
+ let termMs = UInt64(Date().timeIntervalSince1970 * 1000)
205
+ let elapsed = Int((termMs - replayStartMs) / 1000)
206
+
207
+ _netMonitor?.cancel()
208
+ _netMonitor = nil
209
+ _hierarchyTimer?.invalidate()
210
+ _hierarchyTimer = nil
211
+ _stopDurationLimitTimer()
212
+ _detachLifecycle()
213
+
214
+ let metrics: [String: Any] = [
215
+ "crashCount": _crashCount,
216
+ "anrCount": _freezeCount,
217
+ "errorCount": _errorCount,
218
+ "durationSeconds": elapsed,
219
+ "touchCount": _tapCount,
220
+ "scrollCount": _scrollCount,
221
+ "gestureCount": _gestureCount,
222
+ "rageTapCount": _rageCount,
223
+ "deadTapCount": _deadTapCount,
224
+ "screensVisited": _visitedScreens,
225
+ "screenCount": Set(_visitedScreens).count
226
+ ]
227
+
228
+ SegmentDispatcher.shared.evaluateReplayRetention(replayId: sid, metrics: metrics) { [weak self] retain, reason in
229
+ guard let self else { return }
230
+
231
+ // UI operations MUST run on main thread
232
+ DispatchQueue.main.async {
233
+ TelemetryPipeline.shared.shutdown()
234
+ VisualCapture.shared.halt()
235
+ InteractionRecorder.shared.deactivate()
236
+ FaultTracker.shared.deactivate()
237
+ ResponsivenessWatcher.shared.halt()
238
+ }
239
+
240
+ SegmentDispatcher.shared.shipPending()
241
+
242
+ guard !self._finalized else {
243
+ self._clearRecovery()
244
+ completion?(true, true)
245
+ return
246
+ }
247
+ self._finalized = true
248
+
249
+ SegmentDispatcher.shared.concludeReplay(replayId: sid, concludedAt: termMs, backgroundDurationMs: self._bgTimeMs, metrics: metrics) { [weak self] ok in
250
+ if ok { self?._clearRecovery() }
251
+ completion?(true, ok)
252
+ }
253
+ }
254
+
255
+ replayId = nil
256
+ replayStartMs = 0
257
+ }
258
+
259
+ @objc public func redactView(_ view: UIView) {
260
+ VisualCapture.shared.registerRedaction(view)
261
+ }
262
+
263
+ @objc public func unredactView(_ view: UIView) {
264
+ VisualCapture.shared.unregisterRedaction(view)
265
+ }
266
+
267
+ /// Set remote configuration from backend
268
+ /// Called by JS side before startSession to apply server-side settings
269
+ @objc public func setRemoteConfig(
270
+ rejourneyEnabled: Bool,
271
+ recordingEnabled: Bool,
272
+ sampleRate: Int,
273
+ maxRecordingMinutes: Int
274
+ ) {
275
+ self.remoteRejourneyEnabled = rejourneyEnabled
276
+ self.remoteRecordingEnabled = recordingEnabled
277
+ self.remoteSampleRate = sampleRate
278
+ self.remoteMaxRecordingMinutes = maxRecordingMinutes
279
+
280
+ // Set isSampledIn for server-side enforcement
281
+ // recordingEnabled=false means either dashboard disabled OR session sampled out by JS
282
+ TelemetryPipeline.shared.isSampledIn = recordingEnabled
283
+
284
+ // Apply recording settings immediately
285
+ // If recording is disabled, disable visual capture
286
+ if !recordingEnabled {
287
+ visualCaptureEnabled = false
288
+ DiagnosticLog.notice("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
289
+ }
290
+
291
+ // If already recording, restart the duration limit timer with updated config
292
+ if _live {
293
+ _startDurationLimitTimer()
294
+ }
295
+
296
+ DiagnosticLog.notice("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=\(rejourneyEnabled), recordingEnabled=\(recordingEnabled), sampleRate=\(sampleRate)%, maxRecording=\(maxRecordingMinutes)min, isSampledIn=\(recordingEnabled)")
297
+ }
298
+
299
+ @objc public func attachAttribute(key: String, value: String) {
300
+ TelemetryPipeline.shared.recordAttribute(key: key, value: value)
301
+ }
302
+
303
+ @objc public func recordCustomEvent(name: String, payload: String?) {
304
+ TelemetryPipeline.shared.recordCustomEvent(name: name, payload: payload ?? "")
305
+ }
306
+
307
+ @objc public func associateUser(_ userId: String) {
308
+ TelemetryPipeline.shared.recordUserAssociation(userId)
309
+ }
310
+
311
+ @objc public func currentReplayId() -> String {
312
+ replayId ?? ""
313
+ }
314
+
315
+ @objc public func activateGestureRecording() {
316
+ }
317
+
318
+ @objc public func recoverInterruptedReplay(completion: @escaping (String?) -> Void) {
319
+ guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
320
+ completion(nil)
321
+ return
322
+ }
323
+
324
+ let path = docs.appendingPathComponent("rejourney_recovery.json")
325
+
326
+ guard let data = try? Data(contentsOf: path),
327
+ let checkpoint = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
328
+ let recId = checkpoint["replayId"] as? String else {
329
+ completion(nil)
330
+ return
331
+ }
332
+
333
+ let origStart = checkpoint["startMs"] as? UInt64 ?? 0
334
+ let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
335
+
336
+ if let token = checkpoint["apiToken"] as? String {
337
+ SegmentDispatcher.shared.apiToken = token
338
+ }
339
+ if let endpoint = checkpoint["endpoint"] as? String {
340
+ SegmentDispatcher.shared.endpoint = endpoint
341
+ }
342
+
343
+ let crashMetrics: [String: Any] = [
344
+ "crashCount": 1,
345
+ "durationSeconds": Int((nowMs - origStart) / 1000)
346
+ ]
347
+
348
+ SegmentDispatcher.shared.concludeReplay(replayId: recId, concludedAt: nowMs, backgroundDurationMs: 0, metrics: crashMetrics) { [weak self] ok in
349
+ self?._clearRecovery()
350
+ completion(ok ? recId : nil)
351
+ }
352
+ }
353
+
354
+ @objc public func incrementFaultTally() { _crashCount += 1 }
355
+ @objc public func incrementStalledTally() { _freezeCount += 1 }
356
+ @objc public func incrementExceptionTally() { _errorCount += 1 }
357
+ @objc public func incrementTapTally() { _tapCount += 1 }
358
+ @objc public func logScrollAction() { _scrollCount += 1 }
359
+ @objc public func incrementGestureTally() { _gestureCount += 1 }
360
+ @objc public func incrementRageTapTally() { _rageCount += 1 }
361
+ @objc public func incrementDeadTapTally() { _deadTapCount += 1 }
362
+
363
+ @objc public func logScreenView(_ screenId: String) {
364
+ guard !screenId.isEmpty else { return }
365
+ _visitedScreens.append(screenId)
366
+ currentScreenName = screenId
367
+ if hierarchyCaptureEnabled { _captureHierarchy() }
368
+ }
369
+
370
+ private func _initSession() {
371
+ replayStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
372
+ let uuidPart = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
373
+ replayId = "session_\(replayStartMs)_\(uuidPart)"
374
+ _finalized = false
375
+
376
+ _crashCount = 0
377
+ _freezeCount = 0
378
+ _errorCount = 0
379
+ _tapCount = 0
380
+ _scrollCount = 0
381
+ _gestureCount = 0
382
+ _rageCount = 0
383
+ _deadTapCount = 0
384
+ _visitedScreens.removeAll()
385
+ _bgTimeMs = 0
386
+ _bgStartMs = nil
387
+
388
+ TelemetryPipeline.shared.currentReplayId = replayId
389
+ SegmentDispatcher.shared.currentReplayId = replayId
390
+ StabilityMonitor.shared.currentSessionId = replayId
391
+
392
+ _attachLifecycle()
393
+ _saveRecovery()
394
+
395
+ // Record app startup time
396
+ _recordAppStartup()
397
+ }
398
+
399
+ private func _recordAppStartup() {
400
+ let nowSec = Date().timeIntervalSince1970
401
+ let startupDurationMs = Int64((nowSec - ReplayOrchestrator.processStartTime) * 1000)
402
+
403
+ // Only record if it's a reasonable startup time (> 0 and < 60 seconds)
404
+ guard startupDurationMs > 0 && startupDurationMs < 60000 else { return }
405
+
406
+ TelemetryPipeline.shared.recordAppStartup(durationMs: startupDurationMs)
407
+ }
408
+
409
+ private func _applySettings(_ cfg: [String: Any]?) {
410
+ guard let cfg else { return }
411
+ snapshotInterval = cfg["captureRate"] as? Double ?? 0.33
412
+ compressionLevel = cfg["imgCompression"] as? Double ?? 0.5
413
+ visualCaptureEnabled = cfg["captureScreen"] as? Bool ?? true
414
+ interactionCaptureEnabled = cfg["captureAnalytics"] as? Bool ?? true
415
+ faultTrackingEnabled = cfg["captureCrashes"] as? Bool ?? true
416
+ responsivenessCaptureEnabled = cfg["captureANR"] as? Bool ?? true
417
+ consoleCaptureEnabled = cfg["captureLogs"] as? Bool ?? true
418
+ wifiRequired = cfg["wifiOnly"] as? Bool ?? false
419
+ frameBundleSize = cfg["screenshotBatchSize"] as? Int ?? 5
420
+ }
421
+
422
+ private func _monitorNetwork(token: String) {
423
+ _netMonitor = NWPathMonitor()
424
+ _netMonitor?.pathUpdateHandler = { [weak self] path in
425
+ self?.handlePathChange(path: path, token: token)
426
+ }
427
+ _netMonitor?.start(queue: DispatchQueue.global(qos: .utility))
428
+ }
429
+
430
+ private func handlePathChange(path: NWPath, token: String) {
431
+ let canProceed: Bool
432
+
433
+ if path.status != .satisfied {
434
+ canProceed = false
435
+ } else if wifiRequired && !path.isExpensive {
436
+ canProceed = true
437
+ } else if wifiRequired && path.isExpensive {
438
+ canProceed = false
439
+ } else {
440
+ canProceed = true
441
+ }
442
+
443
+ // Extract network interface type
444
+ let networkType: String
445
+ let isExpensive = path.isExpensive
446
+ let isConstrained = path.isConstrained
447
+
448
+ if path.status != .satisfied {
449
+ networkType = "none"
450
+ } else if path.usesInterfaceType(.wifi) {
451
+ networkType = "wifi"
452
+ } else if path.usesInterfaceType(.cellular) {
453
+ networkType = "cellular"
454
+ } else if path.usesInterfaceType(.wiredEthernet) {
455
+ networkType = "wired"
456
+ } else if path.usesInterfaceType(.loopback) {
457
+ networkType = "other"
458
+ } else {
459
+ networkType = "other"
460
+ }
461
+
462
+ DispatchQueue.main.async { [weak self] in
463
+ guard let self else { return }
464
+ self._netReady = canProceed
465
+ self.currentNetworkType = networkType
466
+ self.networkIsExpensive = isExpensive
467
+ self.networkIsConstrained = isConstrained
468
+
469
+ if canProceed && !self._live {
470
+ self._beginRecording(token: token)
471
+ }
472
+ }
473
+ }
474
+
475
+ private func _beginRecording(token: String) {
476
+ guard !_live else { return }
477
+ _live = true
478
+
479
+ self.apiToken = token
480
+ _initSession()
481
+
482
+ // Reactivate the dispatcher in case it was halted from a previous session
483
+ SegmentDispatcher.shared.activate()
484
+ TelemetryPipeline.shared.activate()
485
+
486
+ let renderCfg = _computeRender(fps: 3, tier: "high")
487
+ VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
488
+
489
+ if visualCaptureEnabled { VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs) }
490
+ if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
491
+ if faultTrackingEnabled { FaultTracker.shared.activate() }
492
+ if responsivenessCaptureEnabled { ResponsivenessWatcher.shared.activate() }
493
+ if hierarchyCaptureEnabled { _startHierarchyCapture() }
494
+
495
+ // Start duration limit timer based on remote config
496
+ _startDurationLimitTimer()
497
+ }
498
+
499
+ // MARK: - Duration Limit Timer
500
+
501
+ private func _startDurationLimitTimer() {
502
+ _stopDurationLimitTimer()
503
+
504
+ let maxMinutes = remoteMaxRecordingMinutes
505
+ guard maxMinutes > 0 else { return }
506
+
507
+ let maxMs = UInt64(maxMinutes) * 60 * 1000
508
+ let now = UInt64(Date().timeIntervalSince1970 * 1000)
509
+ let elapsed = now - replayStartMs
510
+ let remaining = maxMs > elapsed ? maxMs - elapsed : 0
511
+
512
+ guard remaining > 0 else {
513
+ DiagnosticLog.notice("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
514
+ endReplay()
515
+ return
516
+ }
517
+
518
+ let workItem = DispatchWorkItem { [weak self] in
519
+ guard let self, self._live else { return }
520
+ DiagnosticLog.notice("[ReplayOrchestrator] Recording duration limit reached (\(maxMinutes)min), stopping session")
521
+ self.endReplay()
522
+ }
523
+ _durationLimitTimer = workItem
524
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(remaining)), execute: workItem)
525
+
526
+ DiagnosticLog.notice("[ReplayOrchestrator] Duration limit timer set: \(remaining / 1000)s remaining (max \(maxMinutes)min)")
527
+ }
528
+
529
+ private func _stopDurationLimitTimer() {
530
+ _durationLimitTimer?.cancel()
531
+ _durationLimitTimer = nil
532
+ }
533
+
534
+ private func _saveRecovery() {
535
+ guard let sid = replayId, let token = apiToken else { return }
536
+ let checkpoint: [String: Any] = ["replayId": sid, "apiToken": token, "startMs": replayStartMs, "endpoint": serverEndpoint]
537
+ guard let data = try? JSONSerialization.data(withJSONObject: checkpoint),
538
+ let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
539
+ try? data.write(to: docs.appendingPathComponent("rejourney_recovery.json"))
540
+ }
541
+
542
+ private func _clearRecovery() {
543
+ guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
544
+ try? FileManager.default.removeItem(at: docs.appendingPathComponent("rejourney_recovery.json"))
545
+ }
546
+
547
+ private func _attachLifecycle() {
548
+ NotificationCenter.default.addObserver(self, selector: #selector(_onBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
549
+ NotificationCenter.default.addObserver(self, selector: #selector(_onForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
550
+ }
551
+
552
+ private func _detachLifecycle() {
553
+ NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
554
+ NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
555
+ }
556
+
557
+ @objc private func _onBackground() {
558
+ _bgStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
559
+ }
560
+
561
+ @objc private func _onForeground() {
562
+ guard let start = _bgStartMs else { return }
563
+ let now = UInt64(Date().timeIntervalSince1970 * 1000)
564
+ _bgTimeMs += (now - start)
565
+ _bgStartMs = nil
566
+ }
567
+
568
+ private func _startHierarchyCapture() {
569
+ _hierarchyTimer?.invalidate()
570
+ // Industry standard: Use default run loop mode (NOT .common)
571
+ // This lets the timer pause during scrolling which prevents stutter
572
+ _hierarchyTimer = Timer.scheduledTimer(withTimeInterval: hierarchyCaptureInterval, repeats: true) { [weak self] _ in
573
+ self?._captureHierarchy()
574
+ }
575
+
576
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
577
+ self?._captureHierarchy()
578
+ }
579
+ }
580
+
581
+ private func _captureHierarchy() {
582
+ guard _live, let sid = replayId else { return }
583
+ if !Thread.isMainThread {
584
+ DispatchQueue.main.async { [weak self] in self?._captureHierarchy() }
585
+ return
586
+ }
587
+
588
+ guard let hierarchy = ViewHierarchyScanner.shared.captureHierarchy() else { return }
589
+
590
+ let hash = _hierarchyHash(hierarchy)
591
+ if hash == _lastHierarchyHash { return }
592
+ _lastHierarchyHash = hash
593
+
594
+ guard let json = try? JSONSerialization.data(withJSONObject: hierarchy) else { return }
595
+ let ts = UInt64(Date().timeIntervalSince1970 * 1000)
596
+
597
+ SegmentDispatcher.shared.transmitHierarchy(replayId: sid, hierarchyPayload: json, timestampMs: ts, completion: nil)
598
+ }
599
+
600
+ private func _hierarchyHash(_ h: [String: Any]) -> String {
601
+ let screen = currentScreenName ?? "unknown"
602
+ var childCount = 0
603
+ if let root = h["root"] as? [String: Any], let children = root["children"] as? [[String: Any]] {
604
+ childCount = children.count
605
+ }
606
+ return "\(screen):\(childCount)"
607
+ }
608
+ }
609
+
610
+ private func _computeRender(fps: Int, tier: String) -> (interval: Double, quality: Double) {
611
+ let interval = 1.0 / Double(max(1, min(fps, 99)))
612
+ let quality: Double
613
+ switch tier.lowercased() {
614
+ case "low": quality = 0.4
615
+ case "standard": quality = 0.5
616
+ case "high": quality = 0.6
617
+ default: quality = 0.5
618
+ }
619
+ return (interval, quality)
620
+ }
621
+
622
+ func computeQualityPreset(targetFps: Int, preset: String) -> (interval: Double, quality: Double) {
623
+ _computeRender(fps: targetFps, tier: preset)
624
+ }