@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.
- package/README.md +77 -3
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
- package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -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 +3 -1
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
- 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 +204 -115
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +1 -0
- package/ios/Recording/RejourneyURLProtocol.swift +216 -0
- package/ios/Recording/ReplayOrchestrator.swift +207 -144
- package/ios/Recording/SegmentDispatcher.swift +8 -0
- package/ios/Recording/StabilityMonitor.swift +40 -32
- package/ios/Recording/TelemetryPipeline.swift +45 -2
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +79 -29
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/DataCompression.swift +2 -2
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/expoRouterTracking.js +137 -0
- package/lib/commonjs/index.js +204 -34
- package/lib/commonjs/sdk/autoTracking.js +262 -100
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/module/expoRouterTracking.js +135 -0
- package/lib/module/index.js +203 -28
- package/lib/module/sdk/autoTracking.js +260 -100
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/expoRouterTracking.d.ts +14 -0
- package/lib/typescript/index.d.ts +2 -2
- package/lib/typescript/sdk/autoTracking.d.ts +14 -1
- package/lib/typescript/types/index.d.ts +56 -5
- package/package.json +23 -3
- package/src/NativeRejourney.ts +8 -5
- package/src/expoRouterTracking.ts +167 -0
- package/src/index.ts +221 -35
- package/src/sdk/autoTracking.ts +286 -114
- package/src/sdk/networkInterceptor.ts +110 -1
- 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
|
-
|
|
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":
|
|
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":
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
453
|
-
archive.append(
|
|
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
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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 =
|
|
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
|
-
|
|
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});
|
|
@@ -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,
|
|
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
|
|