@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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
  3. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  4. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  5. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  6. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
  7. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  8. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
  9. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
  10. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  11. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  12. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
  13. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  14. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
  15. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  16. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  17. package/ios/Engine/DeviceRegistrar.swift +13 -3
  18. package/ios/Engine/RejourneyImpl.swift +202 -133
  19. package/ios/Recording/AnrSentinel.swift +58 -25
  20. package/ios/Recording/InteractionRecorder.swift +29 -0
  21. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  22. package/ios/Recording/ReplayOrchestrator.swift +241 -147
  23. package/ios/Recording/SegmentDispatcher.swift +155 -13
  24. package/ios/Recording/SpecialCases.swift +614 -0
  25. package/ios/Recording/StabilityMonitor.swift +42 -34
  26. package/ios/Recording/TelemetryPipeline.swift +38 -3
  27. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  28. package/ios/Recording/VisualCapture.swift +104 -28
  29. package/ios/Rejourney.mm +27 -8
  30. package/ios/Utility/ImageBlur.swift +0 -1
  31. package/lib/commonjs/index.js +32 -20
  32. package/lib/commonjs/sdk/autoTracking.js +162 -11
  33. package/lib/commonjs/sdk/constants.js +2 -2
  34. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  35. package/lib/commonjs/sdk/utils.js +1 -1
  36. package/lib/module/index.js +32 -20
  37. package/lib/module/sdk/autoTracking.js +162 -11
  38. package/lib/module/sdk/constants.js +2 -2
  39. package/lib/module/sdk/networkInterceptor.js +84 -4
  40. package/lib/module/sdk/utils.js +1 -1
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  43. package/lib/typescript/sdk/constants.d.ts +2 -2
  44. package/lib/typescript/types/index.d.ts +15 -8
  45. package/package.json +4 -4
  46. package/src/NativeRejourney.ts +8 -5
  47. package/src/index.ts +46 -29
  48. package/src/sdk/autoTracking.ts +176 -11
  49. package/src/sdk/constants.ts +2 -2
  50. package/src/sdk/networkInterceptor.ts +110 -1
  51. package/src/sdk/utils.ts +1 -1
  52. 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
- print("[Rejourney] Incident persist failed: \(error)")
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
- print("[Rejourney] Incident persist failed: \(error)")
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
- if ok { try? FileManager.default.removeItem(at: self!._incidentStore) }
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 immediately
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 a short delay for network operations to complete
162
- Thread.sleep(forTimeInterval: 0.5)
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.5
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 configure(snapshotInterval: Double, jpegQuality: Double) {
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.windows.first(where: \.isKeyWindow) else { return }
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
- // 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)
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
- guard let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return }
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 { return }
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 { return }
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
- TelemetryPipeline.shared.submitFrameBundle(
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 = 1.0
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
- 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
- }
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
- recordingEnabled : (BOOL)recordingEnabled
317
- sampleRate : (double)sampleRate
318
- maxRecordingMinutes : (double)maxRecordingMinutes
319
- resolve : (RCTPromiseResolveBlock)resolve
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});
@@ -18,7 +18,6 @@ import UIKit
18
18
  import Accelerate
19
19
 
20
20
  extension UIImage {
21
- /// Apply Gaussian blur to the image
22
21
  func gaussianBlur(radius: CGFloat) -> UIImage? {
23
22
  guard let cgImage = cgImage else { return nil }
24
23