@rejourneyco/react-native 1.0.7 → 1.0.9
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 +1 -1
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
- package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +202 -133
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +29 -0
- package/ios/Recording/RejourneyURLProtocol.swift +168 -0
- package/ios/Recording/ReplayOrchestrator.swift +241 -147
- package/ios/Recording/SegmentDispatcher.swift +155 -13
- package/ios/Recording/SpecialCases.swift +614 -0
- package/ios/Recording/StabilityMonitor.swift +42 -34
- package/ios/Recording/TelemetryPipeline.swift +38 -3
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +104 -28
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/index.js +32 -20
- package/lib/commonjs/sdk/autoTracking.js +162 -11
- package/lib/commonjs/sdk/constants.js +2 -2
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/commonjs/sdk/utils.js +1 -1
- package/lib/module/index.js +32 -20
- package/lib/module/sdk/autoTracking.js +162 -11
- package/lib/module/sdk/constants.js +2 -2
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/module/sdk/utils.js +1 -1
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/sdk/autoTracking.d.ts +3 -1
- package/lib/typescript/sdk/constants.d.ts +2 -2
- package/lib/typescript/types/index.d.ts +15 -8
- package/package.json +4 -4
- package/src/NativeRejourney.ts +8 -5
- package/src/index.ts +46 -29
- package/src/sdk/autoTracking.ts +176 -11
- package/src/sdk/constants.ts +2 -2
- package/src/sdk/networkInterceptor.ts +110 -1
- package/src/sdk/utils.ts +1 -1
- package/src/types/index.ts +16 -9
|
@@ -38,7 +38,7 @@ private func _rjSignalHandler(_ signum: Int32) {
|
|
|
38
38
|
case SIGTRAP: name = "SIGTRAP"
|
|
39
39
|
default: name = "SIG\(signum)"
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
|
|
42
42
|
let incident = IncidentRecord(
|
|
43
43
|
sessionId: StabilityMonitor.shared.currentSessionId ?? "unknown",
|
|
44
44
|
timestampMs: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
@@ -52,60 +52,63 @@ private func _rjSignalHandler(_ signum: Int32) {
|
|
|
52
52
|
"priority": String(format: "%.2f", Thread.current.threadPriority)
|
|
53
53
|
]
|
|
54
54
|
)
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
ReplayOrchestrator.shared.incrementFaultTally()
|
|
57
57
|
StabilityMonitor.shared.persistIncidentSync(incident)
|
|
58
|
-
|
|
58
|
+
|
|
59
|
+
// Flush visual frames to disk for crash safety
|
|
60
|
+
VisualCapture.shared.flushToDisk()
|
|
61
|
+
|
|
59
62
|
signal(signum, SIG_DFL)
|
|
60
63
|
raise(signum)
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
@objc(StabilityMonitor)
|
|
64
67
|
public final class StabilityMonitor: NSObject {
|
|
65
|
-
|
|
68
|
+
|
|
66
69
|
@objc public static let shared = StabilityMonitor()
|
|
67
70
|
@objc public var isMonitoring = false
|
|
68
71
|
@objc public var currentSessionId: String?
|
|
69
|
-
|
|
72
|
+
|
|
70
73
|
private let _incidentStore: URL
|
|
71
74
|
private let _workerQueue = DispatchQueue(label: "co.rejourney.stability", qos: .utility)
|
|
72
|
-
|
|
75
|
+
|
|
73
76
|
private static var _chainedExceptionHandler: NSUncaughtExceptionHandler?
|
|
74
77
|
private static var _chainedSignalHandlers: [Int32: sig_t] = [:]
|
|
75
78
|
private static let _trackedSignals: [Int32] = [SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGSEGV, SIGTRAP]
|
|
76
|
-
|
|
79
|
+
|
|
77
80
|
private override init() {
|
|
78
81
|
let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
79
82
|
_incidentStore = cache.appendingPathComponent("rj_incidents.json")
|
|
80
83
|
super.init()
|
|
81
84
|
}
|
|
82
|
-
|
|
85
|
+
|
|
83
86
|
@objc public func activate() {
|
|
84
87
|
guard !isMonitoring else { return }
|
|
85
88
|
isMonitoring = true
|
|
86
|
-
|
|
89
|
+
|
|
87
90
|
StabilityMonitor._chainedExceptionHandler = NSGetUncaughtExceptionHandler()
|
|
88
91
|
NSSetUncaughtExceptionHandler { ex in
|
|
89
92
|
StabilityMonitor.shared._captureException(ex)
|
|
90
93
|
StabilityMonitor._chainedExceptionHandler?(ex)
|
|
91
94
|
}
|
|
92
|
-
|
|
95
|
+
|
|
93
96
|
for sig in StabilityMonitor._trackedSignals {
|
|
94
97
|
StabilityMonitor._chainedSignalHandlers[sig] = signal(sig, _rjSignalHandler)
|
|
95
98
|
}
|
|
96
|
-
|
|
99
|
+
|
|
97
100
|
_workerQueue.async { [weak self] in
|
|
98
101
|
self?._uploadStoredIncidents()
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
|
-
|
|
104
|
+
|
|
102
105
|
@objc public func deactivate() {
|
|
103
106
|
guard isMonitoring else { return }
|
|
104
107
|
isMonitoring = false
|
|
105
|
-
|
|
108
|
+
|
|
106
109
|
NSSetUncaughtExceptionHandler(nil)
|
|
107
110
|
StabilityMonitor._chainedExceptionHandler = nil
|
|
108
|
-
|
|
111
|
+
|
|
109
112
|
for sig in StabilityMonitor._trackedSignals {
|
|
110
113
|
if let prev = StabilityMonitor._chainedSignalHandlers[sig] {
|
|
111
114
|
signal(sig, prev)
|
|
@@ -115,13 +118,13 @@ public final class StabilityMonitor: NSObject {
|
|
|
115
118
|
}
|
|
116
119
|
StabilityMonitor._chainedSignalHandlers.removeAll()
|
|
117
120
|
}
|
|
118
|
-
|
|
121
|
+
|
|
119
122
|
@objc public func transmitStoredReport() {
|
|
120
123
|
_workerQueue.async { [weak self] in
|
|
121
124
|
self?._uploadStoredIncidents()
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
|
-
|
|
127
|
+
|
|
125
128
|
private func _captureException(_ exception: NSException) {
|
|
126
129
|
let incident = IncidentRecord(
|
|
127
130
|
sessionId: currentSessionId ?? "unknown",
|
|
@@ -132,25 +135,29 @@ public final class StabilityMonitor: NSObject {
|
|
|
132
135
|
frames: _formatFrames(exception.callStackSymbols),
|
|
133
136
|
context: _captureContext()
|
|
134
137
|
)
|
|
135
|
-
|
|
138
|
+
|
|
136
139
|
ReplayOrchestrator.shared.incrementFaultTally()
|
|
137
140
|
_persistIncident(incident)
|
|
141
|
+
|
|
142
|
+
// Flush visual frames to disk for crash safety
|
|
143
|
+
VisualCapture.shared.flushToDisk()
|
|
144
|
+
|
|
138
145
|
Thread.sleep(forTimeInterval: 0.15)
|
|
139
146
|
}
|
|
140
|
-
|
|
147
|
+
|
|
141
148
|
func persistIncidentSync(_ incident: IncidentRecord) {
|
|
142
149
|
do {
|
|
143
150
|
let data = try JSONEncoder().encode(incident)
|
|
144
151
|
try data.write(to: _incidentStore, options: .atomic)
|
|
145
152
|
} catch {
|
|
146
|
-
|
|
153
|
+
DiagnosticLog.fault("[StabilityMonitor] Incident persist failed: \(error)")
|
|
147
154
|
}
|
|
148
155
|
}
|
|
149
|
-
|
|
156
|
+
|
|
150
157
|
private func _formatFrames(_ raw: [String]) -> [String] {
|
|
151
158
|
raw.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
152
159
|
}
|
|
153
|
-
|
|
160
|
+
|
|
154
161
|
private func _captureContext() -> [String: String] {
|
|
155
162
|
[
|
|
156
163
|
"threadName": Thread.current.name ?? "unnamed",
|
|
@@ -158,48 +165,49 @@ public final class StabilityMonitor: NSObject {
|
|
|
158
165
|
"priority": String(format: "%.2f", Thread.current.threadPriority)
|
|
159
166
|
]
|
|
160
167
|
}
|
|
161
|
-
|
|
168
|
+
|
|
162
169
|
private func _persistIncident(_ incident: IncidentRecord) {
|
|
163
170
|
do {
|
|
164
171
|
let data = try JSONEncoder().encode(incident)
|
|
165
172
|
try data.write(to: _incidentStore, options: .atomic)
|
|
166
173
|
} catch {
|
|
167
|
-
|
|
174
|
+
DiagnosticLog.fault("[StabilityMonitor] Incident persist failed: \(error)")
|
|
168
175
|
}
|
|
169
176
|
}
|
|
170
|
-
|
|
177
|
+
|
|
171
178
|
private func _uploadStoredIncidents() {
|
|
172
179
|
guard FileManager.default.fileExists(atPath: _incidentStore.path),
|
|
173
180
|
let data = try? Data(contentsOf: _incidentStore),
|
|
174
181
|
let incident = try? JSONDecoder().decode(IncidentRecord.self, from: data) else { return }
|
|
175
|
-
|
|
182
|
+
|
|
176
183
|
_transmitIncident(incident) { [weak self] ok in
|
|
177
|
-
|
|
184
|
+
guard ok, let self else { return }
|
|
185
|
+
try? FileManager.default.removeItem(at: self._incidentStore)
|
|
178
186
|
}
|
|
179
187
|
}
|
|
180
|
-
|
|
188
|
+
|
|
181
189
|
private func _transmitIncident(_ incident: IncidentRecord, completion: @escaping (Bool) -> Void) {
|
|
182
190
|
let base = SegmentDispatcher.shared.endpoint
|
|
183
191
|
guard let url = URL(string: "\(base)/api/ingest/fault") else {
|
|
184
192
|
completion(false)
|
|
185
193
|
return
|
|
186
194
|
}
|
|
187
|
-
|
|
195
|
+
|
|
188
196
|
var req = URLRequest(url: url)
|
|
189
197
|
req.httpMethod = "POST"
|
|
190
198
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
191
|
-
|
|
199
|
+
|
|
192
200
|
if let key = SegmentDispatcher.shared.apiToken {
|
|
193
201
|
req.setValue(key, forHTTPHeaderField: "x-rejourney-key")
|
|
194
202
|
}
|
|
195
|
-
|
|
203
|
+
|
|
196
204
|
do {
|
|
197
205
|
req.httpBody = try JSONEncoder().encode(incident)
|
|
198
206
|
} catch {
|
|
199
207
|
completion(false)
|
|
200
208
|
return
|
|
201
209
|
}
|
|
202
|
-
|
|
210
|
+
|
|
203
211
|
URLSession.shared.dataTask(with: req) { _, resp, _ in
|
|
204
212
|
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
|
205
213
|
completion(code >= 200 && code < 300)
|
|
@@ -210,13 +218,13 @@ public final class StabilityMonitor: NSObject {
|
|
|
210
218
|
@objc(FaultTracker)
|
|
211
219
|
public final class FaultTracker: NSObject {
|
|
212
220
|
@objc public static let shared = FaultTracker()
|
|
213
|
-
|
|
221
|
+
|
|
214
222
|
private override init() { super.init() }
|
|
215
|
-
|
|
223
|
+
|
|
216
224
|
@objc public func activate() {
|
|
217
225
|
StabilityMonitor.shared.activate()
|
|
218
226
|
}
|
|
219
|
-
|
|
227
|
+
|
|
220
228
|
@objc public func deactivate() {
|
|
221
229
|
StabilityMonitor.shared.deactivate()
|
|
222
230
|
}
|
|
@@ -141,6 +141,10 @@ public final class TelemetryPipeline: NSObject {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
@objc public func getQueueDepth() -> Int {
|
|
145
|
+
_eventRing.count + _frameQueue.count
|
|
146
|
+
}
|
|
147
|
+
|
|
144
148
|
@objc private func _appSuspending() {
|
|
145
149
|
guard !_draining else { return }
|
|
146
150
|
_draining = true
|
|
@@ -150,16 +154,18 @@ public final class TelemetryPipeline: NSObject {
|
|
|
150
154
|
self?._endBackgroundTask()
|
|
151
155
|
}
|
|
152
156
|
|
|
153
|
-
// Flush visual frames to disk
|
|
157
|
+
// Flush visual frames to disk for crash safety
|
|
154
158
|
VisualCapture.shared.flushToDisk()
|
|
159
|
+
// Submit any buffered frames to the upload pipeline (even if below batch threshold)
|
|
160
|
+
VisualCapture.shared.flushBufferToNetwork()
|
|
155
161
|
|
|
156
162
|
// Try to upload pending data with remaining background time
|
|
157
163
|
_serialWorker.async { [weak self] in
|
|
158
164
|
self?._shipPendingEvents()
|
|
159
165
|
self?._shipPendingFrames()
|
|
160
166
|
|
|
161
|
-
// Allow
|
|
162
|
-
Thread.sleep(forTimeInterval: 0
|
|
167
|
+
// Allow time for network operations to complete
|
|
168
|
+
Thread.sleep(forTimeInterval: 2.0)
|
|
163
169
|
|
|
164
170
|
DispatchQueue.main.async {
|
|
165
171
|
self?._endBackgroundTask()
|
|
@@ -300,6 +306,15 @@ public final class TelemetryPipeline: NSObject {
|
|
|
300
306
|
_enqueue(["type": "custom", "timestamp": _ts(), "name": name, "payload": payload])
|
|
301
307
|
}
|
|
302
308
|
|
|
309
|
+
@objc public func recordConsoleLogEvent(level: String, message: String) {
|
|
310
|
+
_enqueue([
|
|
311
|
+
"type": "log",
|
|
312
|
+
"timestamp": _ts(),
|
|
313
|
+
"level": level,
|
|
314
|
+
"message": message
|
|
315
|
+
])
|
|
316
|
+
}
|
|
317
|
+
|
|
303
318
|
@objc public func recordJSErrorEvent(name: String, message: String, stack: String?) {
|
|
304
319
|
var event: [String: Any] = [
|
|
305
320
|
"type": "error",
|
|
@@ -311,6 +326,10 @@ public final class TelemetryPipeline: NSObject {
|
|
|
311
326
|
event["stack"] = stack
|
|
312
327
|
}
|
|
313
328
|
_enqueue(event)
|
|
329
|
+
// Prioritize JS error delivery to reduce loss on fatal terminations.
|
|
330
|
+
_serialWorker.async { [weak self] in
|
|
331
|
+
self?._shipPendingEvents()
|
|
332
|
+
}
|
|
314
333
|
}
|
|
315
334
|
|
|
316
335
|
@objc public func recordAnrEvent(durationMs: Int, stack: String?) {
|
|
@@ -324,6 +343,10 @@ public final class TelemetryPipeline: NSObject {
|
|
|
324
343
|
event["stack"] = stack
|
|
325
344
|
}
|
|
326
345
|
_enqueue(event)
|
|
346
|
+
// Prioritize ANR delivery while the process is still alive.
|
|
347
|
+
_serialWorker.async { [weak self] in
|
|
348
|
+
self?._shipPendingEvents()
|
|
349
|
+
}
|
|
327
350
|
}
|
|
328
351
|
|
|
329
352
|
@objc public func recordUserAssociation(_ userId: String) {
|
|
@@ -485,6 +508,12 @@ private final class EventRingBuffer {
|
|
|
485
508
|
_storage.reserveCapacity(capacity)
|
|
486
509
|
}
|
|
487
510
|
|
|
511
|
+
var count: Int {
|
|
512
|
+
_lock.lock()
|
|
513
|
+
defer { _lock.unlock() }
|
|
514
|
+
return _storage.count
|
|
515
|
+
}
|
|
516
|
+
|
|
488
517
|
func push(_ entry: EventEntry) {
|
|
489
518
|
_lock.lock()
|
|
490
519
|
defer { _lock.unlock() }
|
|
@@ -525,6 +554,12 @@ private final class FrameBundleQueue {
|
|
|
525
554
|
_maxPending = maxPending
|
|
526
555
|
}
|
|
527
556
|
|
|
557
|
+
var count: Int {
|
|
558
|
+
_lock.lock()
|
|
559
|
+
defer { _lock.unlock() }
|
|
560
|
+
return _queue.count
|
|
561
|
+
}
|
|
562
|
+
|
|
528
563
|
func enqueue(_ bundle: PendingFrameBundle) {
|
|
529
564
|
_lock.lock()
|
|
530
565
|
defer { _lock.unlock() }
|
|
@@ -136,6 +136,7 @@ import UIKit
|
|
|
136
136
|
private func _typeName(_ v: UIView) -> String { String(describing: type(of: v)) }
|
|
137
137
|
|
|
138
138
|
private func _isSensitive(_ v: UIView) -> Bool {
|
|
139
|
+
if v.accessibilityHint == "rejourney_occlude" { return true }
|
|
139
140
|
if let tf = v as? UITextField, tf.isSecureTextEntry { return true }
|
|
140
141
|
return false
|
|
141
142
|
}
|
|
@@ -25,8 +25,10 @@ public final class VisualCapture: NSObject {
|
|
|
25
25
|
|
|
26
26
|
@objc public static let shared = VisualCapture()
|
|
27
27
|
|
|
28
|
-
@objc public var snapshotInterval: Double = 0
|
|
28
|
+
@objc public var snapshotInterval: Double = 1.0
|
|
29
29
|
@objc public var quality: CGFloat = 0.5
|
|
30
|
+
/// Capture scale (e.g. 1.25 = capture at 80% linear size). Matches Android for parity; reduces JPEG size.
|
|
31
|
+
@objc public var captureScale: CGFloat = 1.25
|
|
30
32
|
|
|
31
33
|
@objc public var isCapturing: Bool {
|
|
32
34
|
_stateMachine.currentState == .capturing
|
|
@@ -58,6 +60,7 @@ public final class VisualCapture: NSObject {
|
|
|
58
60
|
|
|
59
61
|
// Industry standard batch size (20 frames per batch, not 5)
|
|
60
62
|
private let _batchSize = 20
|
|
63
|
+
|
|
61
64
|
|
|
62
65
|
private override init() {
|
|
63
66
|
_redactionMask = RedactionMask()
|
|
@@ -135,6 +138,13 @@ public final class VisualCapture: NSObject {
|
|
|
135
138
|
_flushBufferToDisk()
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
/// Submit any buffered frames to the upload pipeline immediately
|
|
142
|
+
/// (regardless of batch size threshold). Packages synchronously to
|
|
143
|
+
/// avoid race conditions during backgrounding.
|
|
144
|
+
@objc public func flushBufferToNetwork() {
|
|
145
|
+
_flushBuffer()
|
|
146
|
+
}
|
|
147
|
+
|
|
138
148
|
@objc public func activateDeferredMode() {
|
|
139
149
|
_deferredUntilCommit = true
|
|
140
150
|
}
|
|
@@ -152,9 +162,15 @@ public final class VisualCapture: NSObject {
|
|
|
152
162
|
_redactionMask.remove(view)
|
|
153
163
|
}
|
|
154
164
|
|
|
155
|
-
@objc public func
|
|
165
|
+
@objc public func invalidateMaskCache() {
|
|
166
|
+
_redactionMask.invalidateCache()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@objc public func configure(snapshotInterval: Double, jpegQuality: Double, captureScale: CGFloat = 1.25) {
|
|
156
171
|
self.snapshotInterval = snapshotInterval
|
|
157
172
|
self.quality = CGFloat(jpegQuality)
|
|
173
|
+
self.captureScale = max(1.0, captureScale)
|
|
158
174
|
if _stateMachine.currentState == .capturing {
|
|
159
175
|
_stopCaptureTimer()
|
|
160
176
|
_startCaptureTimer()
|
|
@@ -163,15 +179,12 @@ public final class VisualCapture: NSObject {
|
|
|
163
179
|
|
|
164
180
|
@objc public func snapshotNow() {
|
|
165
181
|
DispatchQueue.main.async { [weak self] in
|
|
166
|
-
self?._captureFrame()
|
|
182
|
+
self?._captureFrame(forced: true)
|
|
167
183
|
}
|
|
168
184
|
}
|
|
169
185
|
|
|
170
186
|
private func _startCaptureTimer() {
|
|
171
187
|
_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
188
|
_captureTimer = Timer.scheduledTimer(withTimeInterval: snapshotInterval, repeats: true) { [weak self] _ in
|
|
176
189
|
self?._captureFrame()
|
|
177
190
|
}
|
|
@@ -182,7 +195,7 @@ public final class VisualCapture: NSObject {
|
|
|
182
195
|
_captureTimer = nil
|
|
183
196
|
}
|
|
184
197
|
|
|
185
|
-
private func _captureFrame() {
|
|
198
|
+
private func _captureFrame(forced: Bool = false) {
|
|
186
199
|
guard _stateMachine.currentState == .capturing else { return }
|
|
187
200
|
|
|
188
201
|
// Skip capture if app is not in foreground (prevents "not in visible window" warnings)
|
|
@@ -190,10 +203,31 @@ public final class VisualCapture: NSObject {
|
|
|
190
203
|
|
|
191
204
|
let frameStart = CFAbsoluteTimeGetCurrent()
|
|
192
205
|
|
|
206
|
+
// Refresh map detection state (very cheap shallow walk)
|
|
207
|
+
SpecialCases.shared.refreshMapState()
|
|
208
|
+
|
|
209
|
+
// Debug-only: confirm capture is running and map state
|
|
210
|
+
if _frameCounter < 5 || _frameCounter % 30 == 0 {
|
|
211
|
+
DiagnosticLog.trace("[VisualCapture] frame#\(_frameCounter) mapVisible=\(SpecialCases.shared.mapVisible) mapIdle=\(SpecialCases.shared.mapIdle) forced=\(forced)")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Map stutter prevention: when a map view is visible and its camera
|
|
215
|
+
// is still moving (user gesture or animation), skip drawHierarchy
|
|
216
|
+
// entirely — this is the call that causes GPU readback stutter on
|
|
217
|
+
// Metal/OpenGL-backed map tiles. We resume capture at 1 FPS once
|
|
218
|
+
// the map SDK reports idle.
|
|
219
|
+
if !forced && SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle {
|
|
220
|
+
DiagnosticLog.trace("[VisualCapture] SKIPPING frame (map moving)")
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
193
224
|
// Capture the pixel buffer on the main thread (required by UIKit),
|
|
194
225
|
// then move JPEG compression to the encode queue to reduce main-thread blocking.
|
|
195
226
|
autoreleasepool {
|
|
196
|
-
guard let window = UIApplication.shared.
|
|
227
|
+
guard let window = UIApplication.shared.connectedScenes
|
|
228
|
+
.compactMap({ $0 as? UIWindowScene })
|
|
229
|
+
.flatMap({ $0.windows })
|
|
230
|
+
.first(where: { $0.isKeyWindow }) else { return }
|
|
197
231
|
let bounds = window.bounds
|
|
198
232
|
// Guard against NaN and invalid bounds that cause CoreGraphics errors
|
|
199
233
|
guard bounds.width > 0, bounds.height > 0 else { return }
|
|
@@ -201,15 +235,18 @@ public final class VisualCapture: NSObject {
|
|
|
201
235
|
guard bounds.width.isFinite && bounds.height.isFinite else { return }
|
|
202
236
|
|
|
203
237
|
let redactRects = _redactionMask.computeRects()
|
|
238
|
+
let scale = max(1.0, captureScale)
|
|
239
|
+
let scaledSize = CGSize(width: bounds.width / scale, height: bounds.height / scale)
|
|
240
|
+
guard scaledSize.width >= 1, scaledSize.height >= 1 else {
|
|
241
|
+
return
|
|
242
|
+
}
|
|
204
243
|
|
|
205
|
-
|
|
206
|
-
let screenScale: CGFloat = 1.25 // Lower scale reduces encoding time significantly
|
|
207
|
-
UIGraphicsBeginImageContextWithOptions(bounds.size, false, screenScale)
|
|
244
|
+
UIGraphicsBeginImageContextWithOptions(scaledSize, false, 1.0)
|
|
208
245
|
guard let context = UIGraphicsGetCurrentContext() else {
|
|
209
246
|
UIGraphicsEndImageContext()
|
|
210
247
|
return
|
|
211
248
|
}
|
|
212
|
-
|
|
249
|
+
context.scaleBy(x: 1.0 / scale, y: 1.0 / scale)
|
|
213
250
|
window.drawHierarchy(in: bounds, afterScreenUpdates: false)
|
|
214
251
|
|
|
215
252
|
// Apply redactions inline while context is open
|
|
@@ -263,6 +300,8 @@ public final class VisualCapture: NSObject {
|
|
|
263
300
|
}
|
|
264
301
|
}
|
|
265
302
|
|
|
303
|
+
|
|
304
|
+
|
|
266
305
|
/// Enforce memory caps to prevent unbounded growth (industry standard backpressure)
|
|
267
306
|
private func _enforceScreenshotCaps() {
|
|
268
307
|
// Called with lock held
|
|
@@ -340,10 +379,20 @@ public final class VisualCapture: NSObject {
|
|
|
340
379
|
|
|
341
380
|
/// Load and upload any pending frames from disk for a session
|
|
342
381
|
@objc public func uploadPendingFrames(sessionId: String) {
|
|
343
|
-
|
|
382
|
+
uploadPendingFrames(sessionId: sessionId, completion: nil)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
public func uploadPendingFrames(sessionId: String, completion: ((Bool) -> Void)? = nil) {
|
|
386
|
+
guard let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
|
387
|
+
completion?(false)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
344
390
|
let framesPath = cacheDir.appendingPathComponent("rj_pending").appendingPathComponent(sessionId).appendingPathComponent("frames")
|
|
345
391
|
|
|
346
|
-
guard let frameFiles = try? FileManager.default.contentsOfDirectory(at: framesPath, includingPropertiesForKeys: nil) else {
|
|
392
|
+
guard let frameFiles = try? FileManager.default.contentsOfDirectory(at: framesPath, includingPropertiesForKeys: nil) else {
|
|
393
|
+
completion?(true)
|
|
394
|
+
return
|
|
395
|
+
}
|
|
347
396
|
|
|
348
397
|
var frames: [(Data, UInt64)] = []
|
|
349
398
|
for file in frameFiles.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
|
@@ -358,18 +407,24 @@ public final class VisualCapture: NSObject {
|
|
|
358
407
|
frames.append((data, ts))
|
|
359
408
|
}
|
|
360
409
|
|
|
361
|
-
guard !frames.isEmpty, let bundle = _packageFrameBundle(images: frames, sessionEpoch: frames.first?.1 ?? 0) else {
|
|
410
|
+
guard !frames.isEmpty, let bundle = _packageFrameBundle(images: frames, sessionEpoch: frames.first?.1 ?? 0) else {
|
|
411
|
+
completion?(frames.isEmpty)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
362
414
|
|
|
363
415
|
let endTs = frames.last?.1 ?? 0
|
|
364
|
-
let fname = "\(sessionId)-\(endTs).tar.gz"
|
|
365
416
|
|
|
366
|
-
|
|
417
|
+
SegmentDispatcher.shared.transmitFrameBundle(
|
|
367
418
|
payload: bundle,
|
|
368
|
-
filename: fname,
|
|
369
419
|
startMs: frames.first?.1 ?? 0,
|
|
370
420
|
endMs: endTs,
|
|
371
421
|
frameCount: frames.count
|
|
372
|
-
)
|
|
422
|
+
) { ok in
|
|
423
|
+
if ok {
|
|
424
|
+
try? FileManager.default.removeItem(at: framesPath)
|
|
425
|
+
}
|
|
426
|
+
completion?(ok)
|
|
427
|
+
}
|
|
373
428
|
}
|
|
374
429
|
|
|
375
430
|
/// Clear pending frames for a session after successful upload
|
|
@@ -478,7 +533,28 @@ private final class RedactionMask {
|
|
|
478
533
|
// sensitive views (text inputs, cameras) don't appear/disappear at 3fps.
|
|
479
534
|
private var _cachedAutoRects: [CGRect] = []
|
|
480
535
|
private var _lastScanTime: CFAbsoluteTime = 0
|
|
481
|
-
private let _scanCacheDurationSec: CFAbsoluteTime =
|
|
536
|
+
private let _scanCacheDurationSec: CFAbsoluteTime = 0.5
|
|
537
|
+
|
|
538
|
+
private var _observers: [Any] = []
|
|
539
|
+
|
|
540
|
+
init() {
|
|
541
|
+
_observers.append(NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
|
|
542
|
+
_observers.append(NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
|
|
543
|
+
_observers.append(NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
|
|
544
|
+
_observers.append(NotificationCenter.default.addObserver(forName: UITextView.textDidChangeNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
deinit {
|
|
548
|
+
for observer in _observers {
|
|
549
|
+
NotificationCenter.default.removeObserver(observer)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
func invalidateCache() {
|
|
554
|
+
_lock.lock()
|
|
555
|
+
_lastScanTime = 0
|
|
556
|
+
_lock.unlock()
|
|
557
|
+
}
|
|
482
558
|
|
|
483
559
|
// View class names that should always be masked (privacy sensitive)
|
|
484
560
|
private let _sensitiveClassNames: Set<String> = [
|
|
@@ -594,14 +670,10 @@ private final class RedactionMask {
|
|
|
594
670
|
}
|
|
595
671
|
|
|
596
672
|
private func _keyWindow() -> UIWindow? {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
.first { $0.isKeyWindow }
|
|
602
|
-
} else {
|
|
603
|
-
return UIApplication.shared.windows.first { $0.isKeyWindow }
|
|
604
|
-
}
|
|
673
|
+
return UIApplication.shared.connectedScenes
|
|
674
|
+
.compactMap { $0 as? UIWindowScene }
|
|
675
|
+
.flatMap { $0.windows }
|
|
676
|
+
.first { $0.isKeyWindow }
|
|
605
677
|
}
|
|
606
678
|
|
|
607
679
|
private func _scanForSensitiveViews(in view: UIView, rects: inout [CGRect], depth: Int = 0) {
|
|
@@ -638,6 +710,10 @@ private final class RedactionMask {
|
|
|
638
710
|
}
|
|
639
711
|
|
|
640
712
|
private func _shouldMask(_ view: UIView) -> Bool {
|
|
713
|
+
if view.accessibilityHint == "rejourney_occlude" {
|
|
714
|
+
return true
|
|
715
|
+
}
|
|
716
|
+
|
|
641
717
|
// 1. Mask ALL text input fields by default (privacy first)
|
|
642
718
|
// This includes password fields, instructions, notes, etc.
|
|
643
719
|
if view is UITextField {
|
package/ios/Rejourney.mm
CHANGED
|
@@ -70,11 +70,11 @@ RCT_EXPORT_MODULE()
|
|
|
70
70
|
|
|
71
71
|
#pragma mark - Tap Event Emission (no-ops, dead tap detection is native-side)
|
|
72
72
|
|
|
73
|
-
RCT_EXPORT_METHOD(addListener:(NSString *)eventName) {
|
|
73
|
+
RCT_EXPORT_METHOD(addListener : (NSString *)eventName) {
|
|
74
74
|
// No-op: dead tap detection is handled natively in TelemetryPipeline
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
RCT_EXPORT_METHOD(removeListeners:(double)count) {
|
|
77
|
+
RCT_EXPORT_METHOD(removeListeners : (double)count) {
|
|
78
78
|
// No-op: dead tap detection is handled natively in TelemetryPipeline
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -195,6 +195,26 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
|
|
|
195
195
|
[impl getUserIdentity:resolve reject:reject];
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
RCT_EXPORT_METHOD(setAnonymousId : (NSString *)anonymousId resolve : (
|
|
199
|
+
RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
|
|
200
|
+
RejourneyImpl *impl = [self ensureImpl];
|
|
201
|
+
if (!impl) {
|
|
202
|
+
resolve(@{@"success" : @NO});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
[impl setAnonymousId:anonymousId resolve:resolve reject:reject];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
RCT_EXPORT_METHOD(getAnonymousId : (RCTPromiseResolveBlock)
|
|
209
|
+
resolve reject : (RCTPromiseRejectBlock)reject) {
|
|
210
|
+
RejourneyImpl *impl = [self ensureImpl];
|
|
211
|
+
if (!impl) {
|
|
212
|
+
resolve([NSNull null]);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
[impl getAnonymousId:resolve reject:reject];
|
|
216
|
+
}
|
|
217
|
+
|
|
198
218
|
#pragma mark - Events and Tracking
|
|
199
219
|
|
|
200
220
|
RCT_EXPORT_METHOD(logEvent : (NSString *)eventType details : (NSDictionary *)
|
|
@@ -312,12 +332,11 @@ RCT_EXPORT_METHOD(setDebugMode : (BOOL)enabled resolve : (
|
|
|
312
332
|
[impl setDebugMode:enabled resolve:resolve reject:reject];
|
|
313
333
|
}
|
|
314
334
|
|
|
315
|
-
RCT_EXPORT_METHOD(setRemoteConfig : (BOOL)rejourneyEnabled
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
reject : (RCTPromiseRejectBlock)reject) {
|
|
335
|
+
RCT_EXPORT_METHOD(setRemoteConfig : (BOOL)rejourneyEnabled recordingEnabled : (
|
|
336
|
+
BOOL)recordingEnabled sampleRate : (double)
|
|
337
|
+
sampleRate maxRecordingMinutes : (double)
|
|
338
|
+
maxRecordingMinutes resolve : (RCTPromiseResolveBlock)
|
|
339
|
+
resolve reject : (RCTPromiseRejectBlock)reject) {
|
|
321
340
|
RejourneyImpl *impl = [self ensureImpl];
|
|
322
341
|
if (!impl) {
|
|
323
342
|
resolve(@{@"success" : @NO});
|