@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.
- package/README.md +29 -0
- 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 +860 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
- package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
- package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
- package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
- package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
- package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
- package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
- package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
- package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
- package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Engine/DeviceRegistrar.swift +288 -0
- package/ios/Engine/DiagnosticLog.swift +387 -0
- package/ios/Engine/RejourneyImpl.swift +719 -0
- package/ios/Recording/AnrSentinel.swift +142 -0
- package/ios/Recording/EventBuffer.swift +326 -0
- package/ios/Recording/InteractionRecorder.swift +428 -0
- package/ios/Recording/ReplayOrchestrator.swift +624 -0
- package/ios/Recording/SegmentDispatcher.swift +492 -0
- package/ios/Recording/StabilityMonitor.swift +223 -0
- package/ios/Recording/TelemetryPipeline.swift +547 -0
- package/ios/Recording/ViewHierarchyScanner.swift +156 -0
- package/ios/Recording/VisualCapture.swift +675 -0
- package/ios/Rejourney.h +38 -0
- package/ios/Rejourney.mm +375 -0
- package/ios/Utility/DataCompression.swift +55 -0
- package/ios/Utility/ImageBlur.swift +89 -0
- package/ios/Utility/RuntimeMethodSwap.swift +41 -0
- package/ios/Utility/ViewIdentifier.swift +37 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +88 -0
- package/lib/commonjs/index.js +1443 -0
- package/lib/commonjs/sdk/autoTracking.js +1087 -0
- package/lib/commonjs/sdk/constants.js +166 -0
- package/lib/commonjs/sdk/errorTracking.js +187 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +205 -0
- package/lib/commonjs/sdk/navigation.js +128 -0
- package/lib/commonjs/sdk/networkInterceptor.js +375 -0
- package/lib/commonjs/sdk/utils.js +433 -0
- package/lib/commonjs/sdk/version.js +13 -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 +83 -0
- package/lib/module/index.js +1341 -0
- package/lib/module/sdk/autoTracking.js +1059 -0
- package/lib/module/sdk/constants.js +154 -0
- package/lib/module/sdk/errorTracking.js +177 -0
- package/lib/module/sdk/index.js +26 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +120 -0
- package/lib/module/sdk/networkInterceptor.js +364 -0
- package/lib/module/sdk/utils.js +412 -0
- package/lib/module/sdk/version.js +7 -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 +160 -0
- package/lib/typescript/components/Mask.d.ts +54 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +226 -0
- package/lib/typescript/sdk/constants.d.ts +138 -0
- package/lib/typescript/sdk/errorTracking.d.ts +47 -0
- package/lib/typescript/sdk/index.d.ts +24 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
- package/lib/typescript/sdk/navigation.d.ts +48 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
- package/lib/typescript/sdk/utils.d.ts +193 -0
- package/lib/typescript/sdk/version.d.ts +6 -0
- package/lib/typescript/types/index.d.ts +618 -0
- package/package.json +122 -0
- package/rejourney.podspec +23 -0
- package/src/NativeRejourney.ts +185 -0
- package/src/components/Mask.tsx +93 -0
- package/src/index.ts +1555 -0
- package/src/sdk/autoTracking.ts +1245 -0
- package/src/sdk/constants.ts +155 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +25 -0
- package/src/sdk/metricsTracking.ts +227 -0
- package/src/sdk/navigation.ts +152 -0
- package/src/sdk/networkInterceptor.ts +423 -0
- package/src/sdk/utils.ts +442 -0
- package/src/sdk/version.ts +6 -0
- package/src/types/expo-router.d.ts +7 -0
- package/src/types/index.ts +709 -0
|
@@ -0,0 +1,675 @@
|
|
|
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 Foundation
|
|
19
|
+
import QuartzCore
|
|
20
|
+
import Accelerate
|
|
21
|
+
import AVFoundation
|
|
22
|
+
|
|
23
|
+
@objc(VisualCapture)
|
|
24
|
+
public final class VisualCapture: NSObject {
|
|
25
|
+
|
|
26
|
+
@objc public static let shared = VisualCapture()
|
|
27
|
+
|
|
28
|
+
@objc public var snapshotInterval: Double = 0.5
|
|
29
|
+
@objc public var quality: CGFloat = 0.5
|
|
30
|
+
|
|
31
|
+
@objc public var isCapturing: Bool {
|
|
32
|
+
_stateMachine.currentState == .capturing
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private let _stateMachine = CaptureStateMachine()
|
|
36
|
+
private var _screenshots: [(Data, UInt64)] = []
|
|
37
|
+
private let _stateLock = NSLock()
|
|
38
|
+
private var _captureTimer: Timer?
|
|
39
|
+
private var _frameCounter: UInt64 = 0
|
|
40
|
+
private var _sessionEpoch: UInt64 = 0
|
|
41
|
+
private var _redactionMask: RedactionMask
|
|
42
|
+
private var _deferredUntilCommit = false
|
|
43
|
+
private var _framesDiskPath: URL?
|
|
44
|
+
private var _currentSessionId: String?
|
|
45
|
+
|
|
46
|
+
// Use OperationQueue like industry standard - serialized, utility QoS
|
|
47
|
+
private let _encodeQueue: OperationQueue = {
|
|
48
|
+
let q = OperationQueue()
|
|
49
|
+
q.maxConcurrentOperationCount = 1
|
|
50
|
+
q.qualityOfService = .utility
|
|
51
|
+
q.name = "co.rejourney.encode"
|
|
52
|
+
return q
|
|
53
|
+
}()
|
|
54
|
+
|
|
55
|
+
// Backpressure limits to prevent stutter
|
|
56
|
+
private let _maxPendingBatches = 50
|
|
57
|
+
private let _maxBufferedScreenshots = 500
|
|
58
|
+
|
|
59
|
+
// Industry standard batch size (20 frames per batch, not 5)
|
|
60
|
+
private let _batchSize = 20
|
|
61
|
+
|
|
62
|
+
private override init() {
|
|
63
|
+
_redactionMask = RedactionMask()
|
|
64
|
+
super.init()
|
|
65
|
+
_setupLifecycleObservers()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
deinit {
|
|
69
|
+
NotificationCenter.default.removeObserver(self)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private func _setupLifecycleObservers() {
|
|
73
|
+
NotificationCenter.default.addObserver(
|
|
74
|
+
self,
|
|
75
|
+
selector: #selector(_handleBackground),
|
|
76
|
+
name: UIApplication.didEnterBackgroundNotification,
|
|
77
|
+
object: nil
|
|
78
|
+
)
|
|
79
|
+
NotificationCenter.default.addObserver(
|
|
80
|
+
self,
|
|
81
|
+
selector: #selector(_handleForeground),
|
|
82
|
+
name: UIApplication.willEnterForegroundNotification,
|
|
83
|
+
object: nil
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@objc private func _handleBackground() {
|
|
88
|
+
// Stop capturing when app goes to background to prevent
|
|
89
|
+
// "Rendering a view that is not in a visible window" warnings
|
|
90
|
+
_stopCaptureTimer()
|
|
91
|
+
|
|
92
|
+
// Flush any pending screenshots immediately before background
|
|
93
|
+
// This ensures we don't lose data when app is backgrounded
|
|
94
|
+
_sendScreenshots()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@objc private func _handleForeground() {
|
|
98
|
+
// Resume capturing when app comes back to foreground
|
|
99
|
+
if _stateMachine.currentState == .capturing {
|
|
100
|
+
_startCaptureTimer()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@objc public func beginCapture(sessionOrigin: UInt64) {
|
|
105
|
+
guard _stateMachine.transition(to: .capturing) else { return }
|
|
106
|
+
_sessionEpoch = sessionOrigin
|
|
107
|
+
_frameCounter = 0
|
|
108
|
+
|
|
109
|
+
// Set up disk persistence for frames
|
|
110
|
+
_currentSessionId = TelemetryPipeline.shared.currentReplayId
|
|
111
|
+
if let sid = _currentSessionId,
|
|
112
|
+
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
|
113
|
+
_framesDiskPath = cacheDir.appendingPathComponent("rj_pending").appendingPathComponent(sid).appendingPathComponent("frames")
|
|
114
|
+
try? FileManager.default.createDirectory(at: _framesDiskPath!, withIntermediateDirectories: true)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_startCaptureTimer()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@objc public func halt() {
|
|
121
|
+
guard _stateMachine.transition(to: .halted) else { return }
|
|
122
|
+
_stopCaptureTimer()
|
|
123
|
+
|
|
124
|
+
// Flush any remaining frames to disk before halting
|
|
125
|
+
_flushBufferToDisk()
|
|
126
|
+
_flushBuffer()
|
|
127
|
+
|
|
128
|
+
_stateLock.lock()
|
|
129
|
+
_screenshots.removeAll()
|
|
130
|
+
_stateLock.unlock()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Synchronously flush all pending frames to disk for crash safety
|
|
134
|
+
@objc public func flushToDisk() {
|
|
135
|
+
_flushBufferToDisk()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@objc public func activateDeferredMode() {
|
|
139
|
+
_deferredUntilCommit = true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@objc public func commitDeferredData() {
|
|
143
|
+
_deferredUntilCommit = false
|
|
144
|
+
_flushBuffer()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@objc public func registerRedaction(_ view: UIView) {
|
|
148
|
+
_redactionMask.add(view)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@objc public func unregisterRedaction(_ view: UIView) {
|
|
152
|
+
_redactionMask.remove(view)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@objc public func configure(snapshotInterval: Double, jpegQuality: Double) {
|
|
156
|
+
self.snapshotInterval = snapshotInterval
|
|
157
|
+
self.quality = CGFloat(jpegQuality)
|
|
158
|
+
if _stateMachine.currentState == .capturing {
|
|
159
|
+
_stopCaptureTimer()
|
|
160
|
+
_startCaptureTimer()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@objc public func snapshotNow() {
|
|
165
|
+
DispatchQueue.main.async { [weak self] in
|
|
166
|
+
self?._captureFrame()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private func _startCaptureTimer() {
|
|
171
|
+
_stopCaptureTimer()
|
|
172
|
+
// Industry standard: Use default run loop mode (NOT .common)
|
|
173
|
+
// This lets the timer pause during scrolling/tracking which prevents stutter
|
|
174
|
+
// The capture will resume when scrolling stops
|
|
175
|
+
_captureTimer = Timer.scheduledTimer(withTimeInterval: snapshotInterval, repeats: true) { [weak self] _ in
|
|
176
|
+
self?._captureFrame()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func _stopCaptureTimer() {
|
|
181
|
+
_captureTimer?.invalidate()
|
|
182
|
+
_captureTimer = nil
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private func _captureFrame() {
|
|
186
|
+
guard _stateMachine.currentState == .capturing else { return }
|
|
187
|
+
|
|
188
|
+
// Skip capture if app is not in foreground (prevents "not in visible window" warnings)
|
|
189
|
+
guard UIApplication.shared.applicationState == .active else { return }
|
|
190
|
+
|
|
191
|
+
let frameStart = CFAbsoluteTimeGetCurrent()
|
|
192
|
+
|
|
193
|
+
// Capture the pixel buffer on the main thread (required by UIKit),
|
|
194
|
+
// then move JPEG compression to the encode queue to reduce main-thread blocking.
|
|
195
|
+
autoreleasepool {
|
|
196
|
+
guard let window = UIApplication.shared.windows.first(where: \.isKeyWindow) else { return }
|
|
197
|
+
let bounds = window.bounds
|
|
198
|
+
// Guard against NaN and invalid bounds that cause CoreGraphics errors
|
|
199
|
+
guard bounds.width > 0, bounds.height > 0 else { return }
|
|
200
|
+
guard !bounds.width.isNaN && !bounds.height.isNaN else { return }
|
|
201
|
+
guard bounds.width.isFinite && bounds.height.isFinite else { return }
|
|
202
|
+
|
|
203
|
+
let redactRects = _redactionMask.computeRects()
|
|
204
|
+
|
|
205
|
+
// Use UIGraphicsBeginImageContextWithOptions for lower overhead (industry pattern)
|
|
206
|
+
let screenScale: CGFloat = 1.25 // Lower scale reduces encoding time significantly
|
|
207
|
+
UIGraphicsBeginImageContextWithOptions(bounds.size, false, screenScale)
|
|
208
|
+
guard let context = UIGraphicsGetCurrentContext() else {
|
|
209
|
+
UIGraphicsEndImageContext()
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
window.drawHierarchy(in: bounds, afterScreenUpdates: false)
|
|
214
|
+
|
|
215
|
+
// Apply redactions inline while context is open
|
|
216
|
+
if !redactRects.isEmpty {
|
|
217
|
+
// Use fully opaque black for privacy masks (no transparency)
|
|
218
|
+
context.setFillColor(UIColor.black.cgColor)
|
|
219
|
+
for r in redactRects {
|
|
220
|
+
// Skip invalid rects that could cause CoreGraphics errors
|
|
221
|
+
guard r.width > 0 && r.height > 0 else { continue }
|
|
222
|
+
guard r.origin.x.isFinite && r.origin.y.isFinite && r.width.isFinite && r.height.isFinite else { continue }
|
|
223
|
+
guard !r.origin.x.isNaN && !r.origin.y.isNaN && !r.width.isNaN && !r.height.isNaN else { continue }
|
|
224
|
+
context.fill(r)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
guard let image = UIGraphicsGetImageFromCurrentImageContext() else {
|
|
229
|
+
UIGraphicsEndImageContext()
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
UIGraphicsEndImageContext()
|
|
233
|
+
|
|
234
|
+
let captureTs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
235
|
+
_frameCounter += 1
|
|
236
|
+
let frameNumber = _frameCounter
|
|
237
|
+
let jpegQuality = quality
|
|
238
|
+
|
|
239
|
+
// Move JPEG compression off the main thread.
|
|
240
|
+
// drawHierarchy must be on main, but jpegData is thread-safe and
|
|
241
|
+
// accounts for ~40-60% of per-frame main-thread cost.
|
|
242
|
+
_encodeQueue.addOperation { [weak self] in
|
|
243
|
+
guard let self else { return }
|
|
244
|
+
guard let data = image.jpegData(compressionQuality: jpegQuality) else { return }
|
|
245
|
+
|
|
246
|
+
// Log frame timing every 30 frames to avoid log spam
|
|
247
|
+
if frameNumber % 30 == 0 {
|
|
248
|
+
let frameDurationMs = (CFAbsoluteTimeGetCurrent() - frameStart) * 1000
|
|
249
|
+
DiagnosticLog.perfFrame(operation: "screenshot", durationMs: frameDurationMs, frameNumber: Int(frameNumber), isMainThread: Thread.isMainThread)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Store in buffer (fast operation)
|
|
253
|
+
self._stateLock.lock()
|
|
254
|
+
self._screenshots.append((data, captureTs))
|
|
255
|
+
self._enforceScreenshotCaps()
|
|
256
|
+
let shouldSend = !self._deferredUntilCommit && self._screenshots.count >= self._batchSize
|
|
257
|
+
self._stateLock.unlock()
|
|
258
|
+
|
|
259
|
+
if shouldSend {
|
|
260
|
+
self._sendScreenshots()
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// Enforce memory caps to prevent unbounded growth (industry standard backpressure)
|
|
267
|
+
private func _enforceScreenshotCaps() {
|
|
268
|
+
// Called with lock held
|
|
269
|
+
if _screenshots.count > _maxBufferedScreenshots {
|
|
270
|
+
_screenshots.removeFirst(_screenshots.count - _maxBufferedScreenshots)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// Send screenshots to server - runs on OperationQueue to avoid blocking main thread
|
|
275
|
+
private func _sendScreenshots() {
|
|
276
|
+
// Check backpressure first - drop if too backed up (prevents stutter)
|
|
277
|
+
guard _encodeQueue.operationCount <= _maxPendingBatches else {
|
|
278
|
+
DiagnosticLog.trace("Dropping screenshot batch due to backlog")
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Copy and clear under lock (fast operation)
|
|
283
|
+
_stateLock.lock()
|
|
284
|
+
let images = _screenshots
|
|
285
|
+
_screenshots.removeAll()
|
|
286
|
+
let sessionEpoch = _sessionEpoch
|
|
287
|
+
_stateLock.unlock()
|
|
288
|
+
|
|
289
|
+
guard !images.isEmpty else { return }
|
|
290
|
+
|
|
291
|
+
// All heavy work (tar, gzip, network) happens in background queue
|
|
292
|
+
_encodeQueue.addOperation { [weak self] in
|
|
293
|
+
self?._packageAndShip(images: images, sessionEpoch: sessionEpoch)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private func _packageAndShip(images: [(Data, UInt64)], sessionEpoch: UInt64) {
|
|
298
|
+
let batchStart = CFAbsoluteTimeGetCurrent()
|
|
299
|
+
|
|
300
|
+
guard let bundle = _packageFrameBundle(images: images, sessionEpoch: sessionEpoch) else { return }
|
|
301
|
+
|
|
302
|
+
let rid = TelemetryPipeline.shared.currentReplayId ?? "unknown"
|
|
303
|
+
let endTs = images.last?.1 ?? sessionEpoch
|
|
304
|
+
let fname = "\(rid)-\(endTs).tar.gz"
|
|
305
|
+
|
|
306
|
+
let packDurationMs = (CFAbsoluteTimeGetCurrent() - batchStart) * 1000
|
|
307
|
+
DiagnosticLog.perfBatch(operation: "package-frames", itemCount: images.count, totalMs: packDurationMs, isMainThread: Thread.isMainThread)
|
|
308
|
+
|
|
309
|
+
// Submit directly - no main thread dispatch needed
|
|
310
|
+
TelemetryPipeline.shared.submitFrameBundle(
|
|
311
|
+
payload: bundle,
|
|
312
|
+
filename: fname,
|
|
313
|
+
startMs: images.first?.1 ?? sessionEpoch,
|
|
314
|
+
endMs: endTs,
|
|
315
|
+
frameCount: images.count
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private func _writeFrameToDisk(jpeg: Data, timestamp: UInt64) {
|
|
320
|
+
guard let path = _framesDiskPath else { return }
|
|
321
|
+
let framePath = path.appendingPathComponent("\(timestamp).jpeg")
|
|
322
|
+
try? jpeg.write(to: framePath)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private func _flushBufferToDisk() {
|
|
326
|
+
// Package any frames still in memory to disk
|
|
327
|
+
_stateLock.lock()
|
|
328
|
+
let frames = _screenshots
|
|
329
|
+
_stateLock.unlock()
|
|
330
|
+
|
|
331
|
+
guard !frames.isEmpty, let path = _framesDiskPath else { return }
|
|
332
|
+
|
|
333
|
+
for (jpeg, timestamp) in frames {
|
|
334
|
+
let framePath = path.appendingPathComponent("\(timestamp).jpeg")
|
|
335
|
+
if !FileManager.default.fileExists(atPath: framePath.path) {
|
|
336
|
+
try? jpeg.write(to: framePath)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/// Load and upload any pending frames from disk for a session
|
|
342
|
+
@objc public func uploadPendingFrames(sessionId: String) {
|
|
343
|
+
guard let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return }
|
|
344
|
+
let framesPath = cacheDir.appendingPathComponent("rj_pending").appendingPathComponent(sessionId).appendingPathComponent("frames")
|
|
345
|
+
|
|
346
|
+
guard let frameFiles = try? FileManager.default.contentsOfDirectory(at: framesPath, includingPropertiesForKeys: nil) else { return }
|
|
347
|
+
|
|
348
|
+
var frames: [(Data, UInt64)] = []
|
|
349
|
+
for file in frameFiles.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
|
350
|
+
guard file.pathExtension == "jpeg",
|
|
351
|
+
let data = try? Data(contentsOf: file) else { continue }
|
|
352
|
+
|
|
353
|
+
// Try to parse timestamp from filename
|
|
354
|
+
let filename = file.deletingPathExtension().lastPathComponent
|
|
355
|
+
let ts = UInt64(filename) ?? 0
|
|
356
|
+
guard ts > 0 else { continue }
|
|
357
|
+
|
|
358
|
+
frames.append((data, ts))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
guard !frames.isEmpty, let bundle = _packageFrameBundle(images: frames, sessionEpoch: frames.first?.1 ?? 0) else { return }
|
|
362
|
+
|
|
363
|
+
let endTs = frames.last?.1 ?? 0
|
|
364
|
+
let fname = "\(sessionId)-\(endTs).tar.gz"
|
|
365
|
+
|
|
366
|
+
TelemetryPipeline.shared.submitFrameBundle(
|
|
367
|
+
payload: bundle,
|
|
368
|
+
filename: fname,
|
|
369
|
+
startMs: frames.first?.1 ?? 0,
|
|
370
|
+
endMs: endTs,
|
|
371
|
+
frameCount: frames.count
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/// Clear pending frames for a session after successful upload
|
|
376
|
+
@objc public func clearPendingFrames(sessionId: String) {
|
|
377
|
+
guard let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return }
|
|
378
|
+
let framesPath = cacheDir.appendingPathComponent("rj_pending").appendingPathComponent(sessionId).appendingPathComponent("frames")
|
|
379
|
+
try? FileManager.default.removeItem(at: framesPath)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private func _flushBuffer() {
|
|
383
|
+
_stateLock.lock()
|
|
384
|
+
let frames = _screenshots
|
|
385
|
+
_screenshots.removeAll()
|
|
386
|
+
_stateLock.unlock()
|
|
387
|
+
|
|
388
|
+
guard !frames.isEmpty else { return }
|
|
389
|
+
|
|
390
|
+
// Clear the disk copies since we're uploading
|
|
391
|
+
if let path = _framesDiskPath {
|
|
392
|
+
for (_, timestamp) in frames {
|
|
393
|
+
let framePath = path.appendingPathComponent("\(timestamp).jpeg")
|
|
394
|
+
try? FileManager.default.removeItem(at: framePath)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
guard let bundle = _packageFrameBundle(images: frames, sessionEpoch: _sessionEpoch) else { return }
|
|
399
|
+
|
|
400
|
+
let rid = TelemetryPipeline.shared.currentReplayId ?? "unknown"
|
|
401
|
+
let endTs = frames.last?.1 ?? _sessionEpoch
|
|
402
|
+
let fname = "\(rid)-\(endTs).tar.gz"
|
|
403
|
+
|
|
404
|
+
// No main thread dispatch - submit directly (fixes stutter)
|
|
405
|
+
TelemetryPipeline.shared.submitFrameBundle(
|
|
406
|
+
payload: bundle,
|
|
407
|
+
filename: fname,
|
|
408
|
+
startMs: frames.first?.1 ?? _sessionEpoch,
|
|
409
|
+
endMs: endTs,
|
|
410
|
+
frameCount: frames.count
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private func _packageFrameBundle(images: [(Data, UInt64)], sessionEpoch: UInt64) -> Data? {
|
|
415
|
+
var archive = Data()
|
|
416
|
+
|
|
417
|
+
for (jpeg, timestamp) in images {
|
|
418
|
+
let name = "\(sessionEpoch)_1_\(timestamp).jpeg"
|
|
419
|
+
archive.append(_tarHeader(name: name, size: jpeg.count))
|
|
420
|
+
archive.append(jpeg)
|
|
421
|
+
let padding = (512 - (jpeg.count % 512)) % 512
|
|
422
|
+
if padding > 0 { archive.append(Data(repeating: 0, count: padding)) }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
archive.append(Data(repeating: 0, count: 1024))
|
|
426
|
+
return archive.gzipCompress()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private func _tarHeader(name: String, size: Int) -> Data {
|
|
430
|
+
var h = Data(count: 512)
|
|
431
|
+
if let nd = name.data(using: .utf8) { h.replaceSubrange(0..<min(100, nd.count), with: nd.prefix(100)) }
|
|
432
|
+
"0000644\0".data(using: .utf8).map { h.replaceSubrange(100..<108, with: $0) }
|
|
433
|
+
let z = "0000000\0".data(using: .utf8)!
|
|
434
|
+
h.replaceSubrange(108..<124, with: z + z)
|
|
435
|
+
String(format: "%011o\0", size).data(using: .utf8).map { h.replaceSubrange(124..<136, with: $0) }
|
|
436
|
+
String(format: "%011o\0", Int(Date().timeIntervalSince1970)).data(using: .utf8).map { h.replaceSubrange(136..<148, with: $0) }
|
|
437
|
+
h[156] = 0x30
|
|
438
|
+
" ".data(using: .utf8).map { h.replaceSubrange(148..<156, with: $0) }
|
|
439
|
+
let sum = h.reduce(0) { $0 + Int($1) }
|
|
440
|
+
String(format: "%06o\0 ", sum).data(using: .utf8).map { h.replaceSubrange(148..<156, with: $0) }
|
|
441
|
+
return h
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private enum CaptureState { case idle, capturing, halted }
|
|
446
|
+
|
|
447
|
+
private final class CaptureStateMachine {
|
|
448
|
+
private var _state: CaptureState = .idle
|
|
449
|
+
private let _lock = NSLock()
|
|
450
|
+
|
|
451
|
+
var currentState: CaptureState {
|
|
452
|
+
_lock.lock()
|
|
453
|
+
defer { _lock.unlock() }
|
|
454
|
+
return _state
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
func transition(to target: CaptureState) -> Bool {
|
|
458
|
+
_lock.lock()
|
|
459
|
+
defer { _lock.unlock() }
|
|
460
|
+
switch (_state, target) {
|
|
461
|
+
case (.idle, .capturing), (.halted, .capturing), (.capturing, .halted):
|
|
462
|
+
_state = target
|
|
463
|
+
return true
|
|
464
|
+
default:
|
|
465
|
+
return _state == target
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private final class RedactionMask {
|
|
471
|
+
private var _explicitViews = NSHashTable<UIView>.weakObjects()
|
|
472
|
+
private let _lock = NSLock()
|
|
473
|
+
|
|
474
|
+
// Cache the hierarchy scan results to avoid scanning every frame.
|
|
475
|
+
// The full recursive scan runs String(describing: type(of:)) reflection
|
|
476
|
+
// on every view in the key window, which is expensive in React Native
|
|
477
|
+
// hierarchies (thousands of views). Caching for ~1s is safe because
|
|
478
|
+
// sensitive views (text inputs, cameras) don't appear/disappear at 3fps.
|
|
479
|
+
private var _cachedAutoRects: [CGRect] = []
|
|
480
|
+
private var _lastScanTime: CFAbsoluteTime = 0
|
|
481
|
+
private let _scanCacheDurationSec: CFAbsoluteTime = 1.0
|
|
482
|
+
|
|
483
|
+
// View class names that should always be masked (privacy sensitive)
|
|
484
|
+
private let _sensitiveClassNames: Set<String> = [
|
|
485
|
+
// Camera views
|
|
486
|
+
"AVCaptureVideoPreviewLayer",
|
|
487
|
+
"CameraView",
|
|
488
|
+
"RCTCameraView",
|
|
489
|
+
"ExpoCamera",
|
|
490
|
+
"EXCameraView",
|
|
491
|
+
// React Native text inputs (internal class names)
|
|
492
|
+
"RCTSinglelineTextInputView",
|
|
493
|
+
"RCTMultilineTextInputView",
|
|
494
|
+
"RCTTextInput",
|
|
495
|
+
"RCTBaseTextInputView",
|
|
496
|
+
"RCTUITextField",
|
|
497
|
+
// Expo text inputs
|
|
498
|
+
"EXTextInput"
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
func add(_ view: UIView) {
|
|
502
|
+
_lock.lock()
|
|
503
|
+
defer { _lock.unlock() }
|
|
504
|
+
_explicitViews.add(view)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
func remove(_ view: UIView) {
|
|
508
|
+
_lock.lock()
|
|
509
|
+
defer { _lock.unlock() }
|
|
510
|
+
_explicitViews.remove(view)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
func computeRects() -> [CGRect] {
|
|
514
|
+
_lock.lock()
|
|
515
|
+
let explicitViews = _explicitViews.allObjects
|
|
516
|
+
_lock.unlock()
|
|
517
|
+
|
|
518
|
+
var rects: [CGRect] = []
|
|
519
|
+
rects.reserveCapacity(explicitViews.count + 20)
|
|
520
|
+
|
|
521
|
+
// 1. Add explicitly registered views (always fresh — these are few)
|
|
522
|
+
for v in explicitViews {
|
|
523
|
+
if let rect = _viewRect(v) {
|
|
524
|
+
rects.append(rect)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 2. Auto-detect sensitive views from a cached hierarchy scan.
|
|
529
|
+
// The full recursive scan is expensive (String(describing:) reflection
|
|
530
|
+
// on every view) so we cache results for ~1s. Explicit views above
|
|
531
|
+
// are always re-evaluated, so newly focused inputs still get masked.
|
|
532
|
+
let now = CFAbsoluteTimeGetCurrent()
|
|
533
|
+
if now - _lastScanTime >= _scanCacheDurationSec {
|
|
534
|
+
_cachedAutoRects.removeAll()
|
|
535
|
+
if let window = _keyWindow() {
|
|
536
|
+
_scanForSensitiveViews(in: window, rects: &_cachedAutoRects)
|
|
537
|
+
}
|
|
538
|
+
_lastScanTime = now
|
|
539
|
+
}
|
|
540
|
+
rects.append(contentsOf: _cachedAutoRects)
|
|
541
|
+
|
|
542
|
+
return rects
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private func _viewRect(_ v: UIView) -> CGRect? {
|
|
546
|
+
guard let w = v.window else { return nil }
|
|
547
|
+
|
|
548
|
+
// Skip views in non-key windows (keyboard windows, system windows).
|
|
549
|
+
// These have transitional layer transforms during animation that cause
|
|
550
|
+
// UIView.convert() to pass NaN to CoreGraphics internally, producing
|
|
551
|
+
// "invalid numeric value (NaN)" errors that we cannot catch because
|
|
552
|
+
// CoreGraphics logs the error before the return value is available.
|
|
553
|
+
if !w.isKeyWindow { return nil }
|
|
554
|
+
|
|
555
|
+
// Guard against views with invalid bounds before conversion
|
|
556
|
+
let viewBounds = v.bounds
|
|
557
|
+
guard viewBounds.width > 0 && viewBounds.height > 0 else { return nil }
|
|
558
|
+
guard viewBounds.width.isFinite && viewBounds.height.isFinite else { return nil }
|
|
559
|
+
guard !viewBounds.width.isNaN && !viewBounds.height.isNaN else { return nil }
|
|
560
|
+
guard viewBounds.origin.x.isFinite && viewBounds.origin.y.isFinite else { return nil }
|
|
561
|
+
guard !viewBounds.origin.x.isNaN && !viewBounds.origin.y.isNaN else { return nil }
|
|
562
|
+
|
|
563
|
+
// During animation, convert() internally passes NaN to CoreGraphics
|
|
564
|
+
// which logs an error even though we guard the output. Skip animated views.
|
|
565
|
+
if v.layer.animationKeys()?.isEmpty == false {
|
|
566
|
+
return nil
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Also check the view's layer transform — keyboard views during transition
|
|
570
|
+
// can have a transform with NaN or degenerate values that cause convert()
|
|
571
|
+
// to produce NaN internally in CoreGraphics before we can catch the result.
|
|
572
|
+
let t = v.layer.transform
|
|
573
|
+
if t.m11.isNaN || t.m22.isNaN || t.m33.isNaN || t.m44.isNaN ||
|
|
574
|
+
t.m41.isNaN || t.m42.isNaN || t.m43.isNaN {
|
|
575
|
+
return nil
|
|
576
|
+
}
|
|
577
|
+
// Degenerate transform (scale=0) will produce zero-area results
|
|
578
|
+
if t.m11 == 0 && t.m22 == 0 {
|
|
579
|
+
return nil
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Also check the window's transform for safety (keyboard windows can have odd transforms)
|
|
583
|
+
let wt = w.layer.transform
|
|
584
|
+
if wt.m11.isNaN || wt.m22.isNaN || wt.m41.isNaN || wt.m42.isNaN {
|
|
585
|
+
return nil
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
let r = v.convert(viewBounds, to: w)
|
|
589
|
+
// Guard against NaN and invalid values that cause CoreGraphics errors
|
|
590
|
+
guard r.width > 0 && r.height > 0 else { return nil }
|
|
591
|
+
guard !r.origin.x.isNaN && !r.origin.y.isNaN && !r.width.isNaN && !r.height.isNaN else { return nil }
|
|
592
|
+
guard r.origin.x.isFinite && r.origin.y.isFinite && r.width.isFinite && r.height.isFinite else { return nil }
|
|
593
|
+
return r
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private func _keyWindow() -> UIWindow? {
|
|
597
|
+
if #available(iOS 15.0, *) {
|
|
598
|
+
return UIApplication.shared.connectedScenes
|
|
599
|
+
.compactMap { $0 as? UIWindowScene }
|
|
600
|
+
.flatMap { $0.windows }
|
|
601
|
+
.first { $0.isKeyWindow }
|
|
602
|
+
} else {
|
|
603
|
+
return UIApplication.shared.windows.first { $0.isKeyWindow }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private func _scanForSensitiveViews(in view: UIView, rects: inout [CGRect], depth: Int = 0) {
|
|
608
|
+
// Limit recursion depth to avoid scanning deep hierarchies
|
|
609
|
+
guard depth < 20 else { return }
|
|
610
|
+
|
|
611
|
+
// Skip hidden, transparent, or zero-sized views entirely
|
|
612
|
+
guard !view.isHidden && view.alpha > 0.01 else { return }
|
|
613
|
+
guard view.bounds.width > 0 && view.bounds.height > 0 else { return }
|
|
614
|
+
|
|
615
|
+
// Skip keyboard windows entirely — their internal views have
|
|
616
|
+
// transitional frames during animation that produce NaN when
|
|
617
|
+
// converted via UIView.convert(_:to:), causing CoreGraphics
|
|
618
|
+
// "invalid numeric value (NaN)" errors. Keyboard content is
|
|
619
|
+
// not meaningful for session replay and is never recorded.
|
|
620
|
+
let className = String(describing: type(of: view))
|
|
621
|
+
if className.contains("UIRemoteKeyboardWindow") ||
|
|
622
|
+
className.contains("UITextEffectsWindow") ||
|
|
623
|
+
className.contains("UIInputSetHostView") ||
|
|
624
|
+
className.contains("UIKeyboard") {
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Check if this view should be masked
|
|
629
|
+
if _shouldMask(view), let rect = _viewRect(view) {
|
|
630
|
+
rects.append(rect)
|
|
631
|
+
return // Don't scan children - parent mask covers them
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Recurse into subviews
|
|
635
|
+
for subview in view.subviews {
|
|
636
|
+
_scanForSensitiveViews(in: subview, rects: &rects, depth: depth + 1)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private func _shouldMask(_ view: UIView) -> Bool {
|
|
641
|
+
// 1. Mask ALL text input fields by default (privacy first)
|
|
642
|
+
// This includes password fields, instructions, notes, etc.
|
|
643
|
+
if view is UITextField {
|
|
644
|
+
return true
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// 2. Mask ALL text views (multiline inputs like instructions, notes, etc.)
|
|
648
|
+
if view is UITextView {
|
|
649
|
+
return true
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// 3. Check class name against known sensitive types
|
|
653
|
+
let className = String(describing: type(of: view))
|
|
654
|
+
if _sensitiveClassNames.contains(className) {
|
|
655
|
+
return true
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 4. Check if class name contains camera-related keywords
|
|
659
|
+
let lowerClassName = className.lowercased()
|
|
660
|
+
if lowerClassName.contains("camera") || lowerClassName.contains("preview") {
|
|
661
|
+
// Verify it's actually a camera preview, not just any view with "camera" in name
|
|
662
|
+
if lowerClassName.contains("video") || lowerClassName.contains("capture") ||
|
|
663
|
+
lowerClassName.contains("avcapture") || view.layer is AVCaptureVideoPreviewLayer {
|
|
664
|
+
return true
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// 5. Check layer type for camera preview layers
|
|
669
|
+
if view.layer.sublayers?.contains(where: { $0 is AVCaptureVideoPreviewLayer }) == true {
|
|
670
|
+
return true
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return false
|
|
674
|
+
}
|
|
675
|
+
}
|