@rejourneyco/react-native 1.0.8 → 1.0.10

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 +77 -3
  2. package/android/src/main/AndroidManifest.xml +6 -0
  3. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
  4. package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
  5. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
  6. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  7. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  8. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  9. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
  10. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
  11. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
  12. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  13. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
  14. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  15. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  16. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
  17. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  18. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  19. package/ios/Engine/DeviceRegistrar.swift +13 -3
  20. package/ios/Engine/RejourneyImpl.swift +204 -115
  21. package/ios/Recording/AnrSentinel.swift +58 -25
  22. package/ios/Recording/InteractionRecorder.swift +1 -0
  23. package/ios/Recording/RejourneyURLProtocol.swift +216 -0
  24. package/ios/Recording/ReplayOrchestrator.swift +207 -144
  25. package/ios/Recording/SegmentDispatcher.swift +8 -0
  26. package/ios/Recording/StabilityMonitor.swift +40 -32
  27. package/ios/Recording/TelemetryPipeline.swift +45 -2
  28. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  29. package/ios/Recording/VisualCapture.swift +79 -29
  30. package/ios/Rejourney.mm +27 -8
  31. package/ios/Utility/DataCompression.swift +2 -2
  32. package/ios/Utility/ImageBlur.swift +0 -1
  33. package/lib/commonjs/expoRouterTracking.js +137 -0
  34. package/lib/commonjs/index.js +204 -34
  35. package/lib/commonjs/sdk/autoTracking.js +262 -100
  36. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  37. package/lib/module/expoRouterTracking.js +135 -0
  38. package/lib/module/index.js +203 -28
  39. package/lib/module/sdk/autoTracking.js +260 -100
  40. package/lib/module/sdk/networkInterceptor.js +84 -4
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/expoRouterTracking.d.ts +14 -0
  43. package/lib/typescript/index.d.ts +2 -2
  44. package/lib/typescript/sdk/autoTracking.d.ts +14 -1
  45. package/lib/typescript/types/index.d.ts +56 -5
  46. package/package.json +23 -3
  47. package/src/NativeRejourney.ts +8 -5
  48. package/src/expoRouterTracking.ts +167 -0
  49. package/src/index.ts +221 -35
  50. package/src/sdk/autoTracking.ts +286 -114
  51. package/src/sdk/networkInterceptor.ts +110 -1
  52. package/src/types/index.ts +58 -6
@@ -173,6 +173,8 @@ final class SegmentDispatcher {
173
173
  backgroundDurationMs: UInt64,
174
174
  metrics: [String: Any]?,
175
175
  currentQueueDepth: Int = 0,
176
+ endReason: String? = nil,
177
+ lifecycleVersion: Int? = nil,
176
178
  completion: @escaping (Bool) -> Void
177
179
  ) {
178
180
  guard let url = URL(string: "\(endpoint)/api/ingest/session/end") else {
@@ -190,6 +192,12 @@ final class SegmentDispatcher {
190
192
  if backgroundDurationMs > 0 { body["totalBackgroundTimeMs"] = backgroundDurationMs }
191
193
  if let m = metrics { body["metrics"] = m }
192
194
  body["sdkTelemetry"] = sdkTelemetrySnapshot(currentQueueDepth: currentQueueDepth)
195
+ if let endReason, !endReason.isEmpty {
196
+ body["endReason"] = endReason
197
+ }
198
+ if let lifecycleVersion, lifecycleVersion > 0 {
199
+ body["lifecycleVersion"] = lifecycleVersion
200
+ }
193
201
 
194
202
  do {
195
203
  req.httpBody = try JSONSerialization.data(withJSONObject: body)
@@ -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,12 +135,16 @@ 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)
@@ -146,11 +153,11 @@ public final class StabilityMonitor: NSObject {
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,7 +165,7 @@ 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)
@@ -167,39 +174,40 @@ public final class StabilityMonitor: NSObject {
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
- 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
  }
@@ -101,6 +101,25 @@ public final class TelemetryPipeline: NSObject {
101
101
  NotificationCenter.default.addObserver(self, selector: #selector(_appSuspending), name: UIApplication.willTerminateNotification, object: nil)
102
102
  }
103
103
 
104
+ /// Pause the heartbeat timer when the app goes to background.
105
+ /// This prevents the pipeline from uploading empty event batches
106
+ /// while backgrounded, which would inflate session duration.
107
+ @objc public func pause() {
108
+ _heartbeat?.invalidate()
109
+ _heartbeat = nil
110
+ }
111
+
112
+ /// Resume the heartbeat timer when the app returns to foreground.
113
+ @objc public func resume() {
114
+ guard _heartbeat == nil else { return }
115
+ DispatchQueue.main.async { [weak self] in
116
+ guard let self else { return }
117
+ self._heartbeat = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
118
+ self?.dispatchNow()
119
+ }
120
+ }
121
+ }
122
+
104
123
  @objc public func shutdown() {
105
124
  _heartbeat?.invalidate()
106
125
  _heartbeat = nil
@@ -207,9 +226,13 @@ public final class TelemetryPipeline: NSObject {
207
226
  let isConstrained = ReplayOrchestrator.shared.networkIsConstrained
208
227
  let isExpensive = ReplayOrchestrator.shared.networkIsExpensive
209
228
 
229
+ // Prefer detailed hardware model (e.g. "iPhone16,1") when available,
230
+ // falling back to the generic UIDevice.model ("iPhone", "iPad", etc.).
231
+ let hardwareModel = (DeviceRegistrar.shared.gatherDeviceProfile()["hwModel"] as? String) ?? device.model
232
+
210
233
  let meta: [String: Any] = [
211
234
  "platform": "ios",
212
- "model": device.model,
235
+ "model": hardwareModel,
213
236
  "osVersion": device.systemVersion,
214
237
  "vendorId": device.identifierForVendor?.uuidString ?? "",
215
238
  "time": Date().timeIntervalSince1970,
@@ -276,9 +299,12 @@ public final class TelemetryPipeline: NSObject {
276
299
  let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
277
300
  let appId = Bundle.main.bundleIdentifier ?? "unknown"
278
301
 
302
+ // Prefer detailed hardware model from DeviceRegistrar when available.
303
+ let hardwareModel = (DeviceRegistrar.shared.gatherDeviceProfile()["hwModel"] as? String) ?? device.model
304
+
279
305
  let meta: [String: Any] = [
280
306
  "platform": "ios",
281
- "model": device.model,
307
+ "model": hardwareModel,
282
308
  "osVersion": device.systemVersion,
283
309
  "vendorId": device.identifierForVendor?.uuidString ?? "",
284
310
  "time": Date().timeIntervalSince1970,
@@ -306,6 +332,15 @@ public final class TelemetryPipeline: NSObject {
306
332
  _enqueue(["type": "custom", "timestamp": _ts(), "name": name, "payload": payload])
307
333
  }
308
334
 
335
+ @objc public func recordConsoleLogEvent(level: String, message: String) {
336
+ _enqueue([
337
+ "type": "log",
338
+ "timestamp": _ts(),
339
+ "level": level,
340
+ "message": message
341
+ ])
342
+ }
343
+
309
344
  @objc public func recordJSErrorEvent(name: String, message: String, stack: String?) {
310
345
  var event: [String: Any] = [
311
346
  "type": "error",
@@ -317,6 +352,10 @@ public final class TelemetryPipeline: NSObject {
317
352
  event["stack"] = stack
318
353
  }
319
354
  _enqueue(event)
355
+ // Prioritize JS error delivery to reduce loss on fatal terminations.
356
+ _serialWorker.async { [weak self] in
357
+ self?._shipPendingEvents()
358
+ }
320
359
  }
321
360
 
322
361
  @objc public func recordAnrEvent(durationMs: Int, stack: String?) {
@@ -330,6 +369,10 @@ public final class TelemetryPipeline: NSObject {
330
369
  event["stack"] = stack
331
370
  }
332
371
  _enqueue(event)
372
+ // Prioritize ANR delivery while the process is still alive.
373
+ _serialWorker.async { [weak self] in
374
+ self?._shipPendingEvents()
375
+ }
333
376
  }
334
377
 
335
378
  @objc public func recordUserAssociation(_ userId: String) {
@@ -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
  }
@@ -162,6 +162,11 @@ public final class VisualCapture: NSObject {
162
162
  _redactionMask.remove(view)
163
163
  }
164
164
 
165
+ @objc public func invalidateMaskCache() {
166
+ _redactionMask.invalidateCache()
167
+ }
168
+
169
+
165
170
  @objc public func configure(snapshotInterval: Double, jpegQuality: Double, captureScale: CGFloat = 1.25) {
166
171
  self.snapshotInterval = snapshotInterval
167
172
  self.quality = CGFloat(jpegQuality)
@@ -322,7 +327,7 @@ public final class VisualCapture: NSObject {
322
327
 
323
328
  guard !images.isEmpty else { return }
324
329
 
325
- // All heavy work (tar, gzip, network) happens in background queue
330
+ // All heavy work (package, gzip, network) happens in background queue
326
331
  _encodeQueue.addOperation { [weak self] in
327
332
  self?._packageAndShip(images: images, sessionEpoch: sessionEpoch)
328
333
  }
@@ -374,10 +379,20 @@ public final class VisualCapture: NSObject {
374
379
 
375
380
  /// Load and upload any pending frames from disk for a session
376
381
  @objc public func uploadPendingFrames(sessionId: String) {
377
- 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
+ }
378
390
  let framesPath = cacheDir.appendingPathComponent("rj_pending").appendingPathComponent(sessionId).appendingPathComponent("frames")
379
391
 
380
- 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
+ }
381
396
 
382
397
  var frames: [(Data, UInt64)] = []
383
398
  for file in frameFiles.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
@@ -392,18 +407,24 @@ public final class VisualCapture: NSObject {
392
407
  frames.append((data, ts))
393
408
  }
394
409
 
395
- 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
+ }
396
414
 
397
415
  let endTs = frames.last?.1 ?? 0
398
- let fname = "\(sessionId)-\(endTs).tar.gz"
399
416
 
400
- TelemetryPipeline.shared.submitFrameBundle(
417
+ SegmentDispatcher.shared.transmitFrameBundle(
401
418
  payload: bundle,
402
- filename: fname,
403
419
  startMs: frames.first?.1 ?? 0,
404
420
  endMs: endTs,
405
421
  frameCount: frames.count
406
- )
422
+ ) { ok in
423
+ if ok {
424
+ try? FileManager.default.removeItem(at: framesPath)
425
+ }
426
+ completion?(ok)
427
+ }
407
428
  }
408
429
 
409
430
  /// Clear pending frames for a session after successful upload
@@ -445,34 +466,38 @@ public final class VisualCapture: NSObject {
445
466
  )
446
467
  }
447
468
 
469
+ /// Android-compatible binary format: [8-byte BE timestamp offset][4-byte BE size][jpeg] per frame. Backend auto-detects.
448
470
  private func _packageFrameBundle(images: [(Data, UInt64)], sessionEpoch: UInt64) -> Data? {
449
471
  var archive = Data()
450
-
451
472
  for (jpeg, timestamp) in images {
452
- let name = "\(sessionEpoch)_1_\(timestamp).jpeg"
453
- archive.append(_tarHeader(name: name, size: jpeg.count))
473
+ let tsOffset = timestamp - sessionEpoch
474
+ archive.append(_uint64BigEndian(tsOffset))
475
+ archive.append(_uint32BigEndian(UInt32(jpeg.count)))
454
476
  archive.append(jpeg)
455
- let padding = (512 - (jpeg.count % 512)) % 512
456
- if padding > 0 { archive.append(Data(repeating: 0, count: padding)) }
457
477
  }
458
-
459
- archive.append(Data(repeating: 0, count: 1024))
460
478
  return archive.gzipCompress()
461
479
  }
462
480
 
463
- private func _tarHeader(name: String, size: Int) -> Data {
464
- var h = Data(count: 512)
465
- if let nd = name.data(using: .utf8) { h.replaceSubrange(0..<min(100, nd.count), with: nd.prefix(100)) }
466
- "0000644\0".data(using: .utf8).map { h.replaceSubrange(100..<108, with: $0) }
467
- let z = "0000000\0".data(using: .utf8)!
468
- h.replaceSubrange(108..<124, with: z + z)
469
- String(format: "%011o\0", size).data(using: .utf8).map { h.replaceSubrange(124..<136, with: $0) }
470
- String(format: "%011o\0", Int(Date().timeIntervalSince1970)).data(using: .utf8).map { h.replaceSubrange(136..<148, with: $0) }
471
- h[156] = 0x30
472
- " ".data(using: .utf8).map { h.replaceSubrange(148..<156, with: $0) }
473
- let sum = h.reduce(0) { $0 + Int($1) }
474
- String(format: "%06o\0 ", sum).data(using: .utf8).map { h.replaceSubrange(148..<156, with: $0) }
475
- return h
481
+ private func _uint64BigEndian(_ value: UInt64) -> Data {
482
+ Data([
483
+ UInt8((value >> 56) & 0xff),
484
+ UInt8((value >> 48) & 0xff),
485
+ UInt8((value >> 40) & 0xff),
486
+ UInt8((value >> 32) & 0xff),
487
+ UInt8((value >> 24) & 0xff),
488
+ UInt8((value >> 16) & 0xff),
489
+ UInt8((value >> 8) & 0xff),
490
+ UInt8(value & 0xff)
491
+ ])
492
+ }
493
+
494
+ private func _uint32BigEndian(_ value: UInt32) -> Data {
495
+ Data([
496
+ UInt8((value >> 24) & 0xff),
497
+ UInt8((value >> 16) & 0xff),
498
+ UInt8((value >> 8) & 0xff),
499
+ UInt8(value & 0xff)
500
+ ])
476
501
  }
477
502
  }
478
503
 
@@ -512,7 +537,28 @@ private final class RedactionMask {
512
537
  // sensitive views (text inputs, cameras) don't appear/disappear at 3fps.
513
538
  private var _cachedAutoRects: [CGRect] = []
514
539
  private var _lastScanTime: CFAbsoluteTime = 0
515
- private let _scanCacheDurationSec: CFAbsoluteTime = 1.0
540
+ private let _scanCacheDurationSec: CFAbsoluteTime = 0.5
541
+
542
+ private var _observers: [Any] = []
543
+
544
+ init() {
545
+ _observers.append(NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
546
+ _observers.append(NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
547
+ _observers.append(NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
548
+ _observers.append(NotificationCenter.default.addObserver(forName: UITextView.textDidChangeNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
549
+ }
550
+
551
+ deinit {
552
+ for observer in _observers {
553
+ NotificationCenter.default.removeObserver(observer)
554
+ }
555
+ }
556
+
557
+ func invalidateCache() {
558
+ _lock.lock()
559
+ _lastScanTime = 0
560
+ _lock.unlock()
561
+ }
516
562
 
517
563
  // View class names that should always be masked (privacy sensitive)
518
564
  private let _sensitiveClassNames: Set<String> = [
@@ -668,6 +714,10 @@ private final class RedactionMask {
668
714
  }
669
715
 
670
716
  private func _shouldMask(_ view: UIView) -> Bool {
717
+ if view.accessibilityHint == "rejourney_occlude" {
718
+ return true
719
+ }
720
+
671
721
  // 1. Mask ALL text input fields by default (privacy first)
672
722
  // This includes password fields, instructions, notes, etc.
673
723
  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});
@@ -26,8 +26,8 @@ extension Data {
26
26
  stream.avail_in = uint(self.count)
27
27
  stream.total_out = 0
28
28
 
29
- // MAX_WBITS + 16 = gzip format
30
- if deflateInit2_(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, MAX_WBITS + 16, 8, Z_DEFAULT_STRATEGY, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) != Z_OK {
29
+ // MAX_WBITS + 16 = gzip format; level 9 for best ratio (smaller S3 payloads)
30
+ if deflateInit2_(&stream, 9, Z_DEFLATED, MAX_WBITS + 16, 8, Z_DEFAULT_STRATEGY, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) != Z_OK {
31
31
  return nil
32
32
  }
33
33
 
@@ -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