@rejourneyco/react-native 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +29 -0
  2. package/android/build.gradle.kts +135 -0
  3. package/android/consumer-rules.pro +10 -0
  4. package/android/proguard-rules.pro +1 -0
  5. package/android/src/main/AndroidManifest.xml +15 -0
  6. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
  7. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
  8. package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
  9. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
  10. package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
  11. package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
  12. package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
  13. package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
  14. package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
  15. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
  16. package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
  17. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
  18. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
  19. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
  20. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
  21. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
  22. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
  23. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
  24. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
  25. package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
  26. package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
  27. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
  28. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  29. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
  30. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  31. package/ios/Engine/DeviceRegistrar.swift +288 -0
  32. package/ios/Engine/DiagnosticLog.swift +387 -0
  33. package/ios/Engine/RejourneyImpl.swift +719 -0
  34. package/ios/Recording/AnrSentinel.swift +142 -0
  35. package/ios/Recording/EventBuffer.swift +326 -0
  36. package/ios/Recording/InteractionRecorder.swift +428 -0
  37. package/ios/Recording/ReplayOrchestrator.swift +624 -0
  38. package/ios/Recording/SegmentDispatcher.swift +492 -0
  39. package/ios/Recording/StabilityMonitor.swift +223 -0
  40. package/ios/Recording/TelemetryPipeline.swift +547 -0
  41. package/ios/Recording/ViewHierarchyScanner.swift +156 -0
  42. package/ios/Recording/VisualCapture.swift +675 -0
  43. package/ios/Rejourney.h +38 -0
  44. package/ios/Rejourney.mm +375 -0
  45. package/ios/Utility/DataCompression.swift +55 -0
  46. package/ios/Utility/ImageBlur.swift +89 -0
  47. package/ios/Utility/RuntimeMethodSwap.swift +41 -0
  48. package/ios/Utility/ViewIdentifier.swift +37 -0
  49. package/lib/commonjs/NativeRejourney.js +40 -0
  50. package/lib/commonjs/components/Mask.js +88 -0
  51. package/lib/commonjs/index.js +1443 -0
  52. package/lib/commonjs/sdk/autoTracking.js +1087 -0
  53. package/lib/commonjs/sdk/constants.js +166 -0
  54. package/lib/commonjs/sdk/errorTracking.js +187 -0
  55. package/lib/commonjs/sdk/index.js +50 -0
  56. package/lib/commonjs/sdk/metricsTracking.js +205 -0
  57. package/lib/commonjs/sdk/navigation.js +128 -0
  58. package/lib/commonjs/sdk/networkInterceptor.js +375 -0
  59. package/lib/commonjs/sdk/utils.js +433 -0
  60. package/lib/commonjs/sdk/version.js +13 -0
  61. package/lib/commonjs/types/expo-router.d.js +2 -0
  62. package/lib/commonjs/types/index.js +2 -0
  63. package/lib/module/NativeRejourney.js +38 -0
  64. package/lib/module/components/Mask.js +83 -0
  65. package/lib/module/index.js +1341 -0
  66. package/lib/module/sdk/autoTracking.js +1059 -0
  67. package/lib/module/sdk/constants.js +154 -0
  68. package/lib/module/sdk/errorTracking.js +177 -0
  69. package/lib/module/sdk/index.js +26 -0
  70. package/lib/module/sdk/metricsTracking.js +187 -0
  71. package/lib/module/sdk/navigation.js +120 -0
  72. package/lib/module/sdk/networkInterceptor.js +364 -0
  73. package/lib/module/sdk/utils.js +412 -0
  74. package/lib/module/sdk/version.js +7 -0
  75. package/lib/module/types/expo-router.d.js +2 -0
  76. package/lib/module/types/index.js +2 -0
  77. package/lib/typescript/NativeRejourney.d.ts +160 -0
  78. package/lib/typescript/components/Mask.d.ts +54 -0
  79. package/lib/typescript/index.d.ts +117 -0
  80. package/lib/typescript/sdk/autoTracking.d.ts +226 -0
  81. package/lib/typescript/sdk/constants.d.ts +138 -0
  82. package/lib/typescript/sdk/errorTracking.d.ts +47 -0
  83. package/lib/typescript/sdk/index.d.ts +24 -0
  84. package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
  85. package/lib/typescript/sdk/navigation.d.ts +48 -0
  86. package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
  87. package/lib/typescript/sdk/utils.d.ts +193 -0
  88. package/lib/typescript/sdk/version.d.ts +6 -0
  89. package/lib/typescript/types/index.d.ts +618 -0
  90. package/package.json +122 -0
  91. package/rejourney.podspec +23 -0
  92. package/src/NativeRejourney.ts +185 -0
  93. package/src/components/Mask.tsx +93 -0
  94. package/src/index.ts +1555 -0
  95. package/src/sdk/autoTracking.ts +1245 -0
  96. package/src/sdk/constants.ts +155 -0
  97. package/src/sdk/errorTracking.ts +231 -0
  98. package/src/sdk/index.ts +25 -0
  99. package/src/sdk/metricsTracking.ts +227 -0
  100. package/src/sdk/navigation.ts +152 -0
  101. package/src/sdk/networkInterceptor.ts +423 -0
  102. package/src/sdk/utils.ts +442 -0
  103. package/src/sdk/version.ts +6 -0
  104. package/src/types/expo-router.d.ts +7 -0
  105. package/src/types/index.ts +709 -0
@@ -0,0 +1,719 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import UIKit
18
+ import React
19
+ import CommonCrypto
20
+
21
+ @objc(RejourneyImpl)
22
+ public final class RejourneyImpl: NSObject {
23
+ @objc public static let shared = RejourneyImpl()
24
+ @objc public static var sdkVersion = "1.0.1"
25
+
26
+ // MARK: - State Machine
27
+
28
+ private enum SessionState {
29
+ case idle
30
+ case active(sessionId: String, startTime: TimeInterval)
31
+ case paused(sessionId: String, startTime: TimeInterval)
32
+ case terminated
33
+ }
34
+
35
+ private var state: SessionState = .idle
36
+ private let stateLock = NSLock()
37
+
38
+ // MARK: - Internal Storage
39
+
40
+ private var currentUserIdentity: String?
41
+ private var internalEventStream: [[String: Any]] = []
42
+ private var backgroundStartTime: TimeInterval?
43
+ private var lastSessionConfig: [String: Any]?
44
+ private var lastApiUrl: String?
45
+ private var lastPublicKey: String?
46
+
47
+ // Session timeout threshold (60 seconds)
48
+ private let sessionTimeoutSeconds: TimeInterval = 60
49
+
50
+ private let userIdentityKey = "com.rejourney.user.identity"
51
+
52
+ public override init() {
53
+ super.init()
54
+ setupLifecycleListeners()
55
+ _loadPersistedIdentity()
56
+ }
57
+
58
+ private func _loadPersistedIdentity() {
59
+ if let persisted = UserDefaults.standard.string(forKey: userIdentityKey), !persisted.isEmpty {
60
+ self.currentUserIdentity = persisted
61
+ DiagnosticLog.notice("[Rejourney] Restored persisted user identity: \(persisted)")
62
+ }
63
+ }
64
+
65
+ deinit {
66
+ NotificationCenter.default.removeObserver(self)
67
+ }
68
+
69
+ private func setupLifecycleListeners() {
70
+ let center = NotificationCenter.default
71
+ center.addObserver(self, selector: #selector(handleTermination), name: UIApplication.willTerminateNotification, object: nil)
72
+ center.addObserver(self, selector: #selector(handleBackgrounding), name: UIApplication.didEnterBackgroundNotification, object: nil)
73
+ center.addObserver(self, selector: #selector(handleForegrounding), name: UIApplication.willEnterForegroundNotification, object: nil)
74
+ }
75
+
76
+ // MARK: - State Transitions
77
+
78
+ @objc private func handleTermination() {
79
+ stateLock.lock()
80
+ defer { stateLock.unlock() }
81
+
82
+ switch state {
83
+ case .active, .paused:
84
+ state = .terminated
85
+ TelemetryPipeline.shared.finalizeAndShip()
86
+ SegmentDispatcher.shared.shipPending()
87
+ default:
88
+ break
89
+ }
90
+ }
91
+
92
+ @objc private func handleBackgrounding() {
93
+ stateLock.lock()
94
+ defer { stateLock.unlock() }
95
+
96
+ if case .active(let sid, let start) = state {
97
+ state = .paused(sessionId: sid, startTime: start)
98
+ backgroundStartTime = Date().timeIntervalSince1970
99
+ DiagnosticLog.notice("[Rejourney] ⏸️ Session '\(sid)' paused (app backgrounded)")
100
+ TelemetryPipeline.shared.dispatchNow()
101
+ SegmentDispatcher.shared.shipPending()
102
+ }
103
+ }
104
+
105
+ @objc private func handleForegrounding() {
106
+ // Dispatch to avoid blocking the main thread notification handler
107
+ DispatchQueue.main.async { [weak self] in
108
+ self?._processForegrounding()
109
+ }
110
+ }
111
+
112
+ private func _processForegrounding() {
113
+ stateLock.lock()
114
+
115
+ guard case .paused(let sid, let start) = state else {
116
+ DiagnosticLog.trace("[Rejourney] Foreground: not in paused state, ignoring")
117
+ stateLock.unlock()
118
+ return
119
+ }
120
+
121
+ // Check if we've been in background longer than the timeout
122
+ let backgroundDuration: TimeInterval
123
+ if let bgStart = backgroundStartTime {
124
+ backgroundDuration = Date().timeIntervalSince1970 - bgStart
125
+ } else {
126
+ backgroundDuration = 0
127
+ }
128
+ backgroundStartTime = nil
129
+
130
+ DiagnosticLog.notice("[Rejourney] App foregrounded after \(Int(backgroundDuration))s (timeout: \(Int(sessionTimeoutSeconds))s)")
131
+
132
+ if backgroundDuration > sessionTimeoutSeconds {
133
+ // End current session and start a new one
134
+ state = .idle
135
+ stateLock.unlock()
136
+
137
+ DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '\(sid)' and creating new one")
138
+
139
+ // End the old session asynchronously, then start new one
140
+ // Use a background queue to avoid blocking main thread
141
+ DispatchQueue.global(qos: .utility).async { [weak self] in
142
+ ReplayOrchestrator.shared.endReplay { success, uploaded in
143
+ DiagnosticLog.notice("[Rejourney] Old session ended (success: \(success), uploaded: \(uploaded))")
144
+ // Start a new session with preserved config and user identity
145
+ DispatchQueue.main.async {
146
+ self?._startNewSessionAfterTimeout()
147
+ }
148
+ }
149
+ }
150
+ } else {
151
+ // Resume existing session
152
+ state = .active(sessionId: sid, startTime: start)
153
+ stateLock.unlock()
154
+
155
+ DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '\(sid)'")
156
+
157
+ // Record the foreground event with background duration
158
+ let bgMs = UInt64(backgroundDuration * 1000)
159
+ TelemetryPipeline.shared.recordAppForeground(totalBackgroundTimeMs: bgMs)
160
+
161
+ StabilityMonitor.shared.transmitStoredReport()
162
+ }
163
+ }
164
+
165
+ private func _startNewSessionAfterTimeout() {
166
+ guard let apiUrl = lastApiUrl, let publicKey = lastPublicKey else {
167
+ DiagnosticLog.caution("[Rejourney] Cannot restart session - missing API config")
168
+ return
169
+ }
170
+
171
+ let savedUserId = currentUserIdentity
172
+
173
+ DiagnosticLog.notice("[Rejourney] Starting new session after timeout (user: \(savedUserId ?? "nil"))")
174
+
175
+ // Use a faster path: directly call beginSessionFast if credentials are still valid
176
+ // This avoids the network roundtrip for credential re-fetch
177
+ DispatchQueue.main.async { [weak self] in
178
+ guard let self else { return }
179
+
180
+ // Try the fast path first - if credentials are still valid
181
+ if let existingCred = DeviceRegistrar.shared.uploadCredential, DeviceRegistrar.shared.credentialValid {
182
+ DiagnosticLog.notice("[Rejourney] Using cached credentials for fast session restart")
183
+ ReplayOrchestrator.shared.beginReplayFast(
184
+ apiToken: publicKey,
185
+ serverEndpoint: apiUrl,
186
+ credential: existingCred,
187
+ captureSettings: self.lastSessionConfig
188
+ )
189
+ } else {
190
+ // Fall back to full credential fetch
191
+ DiagnosticLog.notice("[Rejourney] No cached credentials, doing full session start")
192
+ ReplayOrchestrator.shared.beginReplay(
193
+ apiToken: publicKey,
194
+ serverEndpoint: apiUrl,
195
+ captureSettings: self.lastSessionConfig
196
+ )
197
+ }
198
+
199
+ // Poll for session to be ready (up to 3 seconds)
200
+ self._waitForSessionReady(savedUserId: savedUserId, attempts: 0)
201
+ }
202
+ }
203
+
204
+ private func _waitForSessionReady(savedUserId: String?, attempts: Int) {
205
+ let maxAttempts = 30 // 3 seconds max (30 * 100ms)
206
+
207
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
208
+ guard let self else { return }
209
+
210
+ // Check if ReplayOrchestrator has generated a new session ID
211
+ if let newSid = ReplayOrchestrator.shared.replayId, !newSid.isEmpty {
212
+ let start = Date().timeIntervalSince1970
213
+
214
+ self.stateLock.lock()
215
+ self.state = .active(sessionId: newSid, startTime: start)
216
+ self.stateLock.unlock()
217
+
218
+ ReplayOrchestrator.shared.activateGestureRecording()
219
+
220
+ // Re-apply user identity if it was set
221
+ if let userId = savedUserId, userId != "anonymous", !userId.hasPrefix("anon_") {
222
+ ReplayOrchestrator.shared.associateUser(userId)
223
+ DiagnosticLog.notice("[Rejourney] ✅ Restored user identity '\(userId)' to new session \(newSid)")
224
+ }
225
+
226
+ DiagnosticLog.replayBegan(newSid)
227
+ DiagnosticLog.notice("[Rejourney] ✅ New session started: \(newSid)")
228
+ } else if attempts < maxAttempts {
229
+ // Keep polling
230
+ self._waitForSessionReady(savedUserId: savedUserId, attempts: attempts + 1)
231
+ } else {
232
+ DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
233
+ }
234
+ }
235
+ }
236
+
237
+ // MARK: - Public API
238
+
239
+ @objc(startSession:apiUrl:publicKey:resolve:reject:)
240
+ public func startSession(
241
+ _ userId: String,
242
+ apiUrl: String,
243
+ publicKey: String,
244
+ resolve: @escaping RCTPromiseResolveBlock,
245
+ reject: @escaping RCTPromiseRejectBlock
246
+ ) {
247
+ startSessionWithOptions(
248
+ [
249
+ "userId": userId,
250
+ "apiUrl": apiUrl,
251
+ "publicKey": publicKey
252
+ ] as NSDictionary,
253
+ resolve: resolve,
254
+ reject: reject
255
+ )
256
+ }
257
+
258
+ @objc(startSessionWithOptions:resolve:reject:)
259
+ public func startSessionWithOptions(
260
+ _ options: NSDictionary,
261
+ resolve: @escaping RCTPromiseResolveBlock,
262
+ reject: @escaping RCTPromiseRejectBlock
263
+ ) {
264
+ if let debug = options["debug"] as? Bool, debug {
265
+ DiagnosticLog.setVerbose(true)
266
+ DiagnosticLog.notice("[Rejourney] Debug mode ENABLED - verbose logging active")
267
+ }
268
+
269
+ let startParams = PerformanceSnapshot.capture()
270
+
271
+ let userId = options["userId"] as? String ?? "anonymous"
272
+ let apiUrl = options["apiUrl"] as? String ?? "https://api.rejourney.co"
273
+ let publicKey = options["publicKey"] as? String ?? ""
274
+
275
+ guard !publicKey.isEmpty else {
276
+ reject("INVALID_KEY", "publicKey is required", nil)
277
+ return
278
+ }
279
+
280
+ var config: [String: Any] = [:]
281
+ if let val = options["captureScreen"] as? Bool { config["captureScreen"] = val }
282
+ if let val = options["captureAnalytics"] as? Bool { config["captureAnalytics"] = val }
283
+ if let val = options["captureCrashes"] as? Bool { config["captureCrashes"] = val }
284
+ if let val = options["captureANR"] as? Bool { config["captureANR"] = val }
285
+ if let val = options["wifiOnly"] as? Bool { config["wifiOnly"] = val }
286
+
287
+ if let fps = options["fps"] as? Int {
288
+ config["captureRate"] = 1.0 / Double(max(1, min(fps, 30)))
289
+ }
290
+
291
+ if let quality = options["quality"] as? String {
292
+ switch quality.lowercased() {
293
+ case "low": config["imgCompression"] = 0.4
294
+ case "high": config["imgCompression"] = 0.7
295
+ default: config["imgCompression"] = 0.5
296
+ }
297
+ }
298
+
299
+ // Critical: Ensure async dispatch to allow React Native bridge to return
300
+ DispatchQueue.main.async { [weak self] in
301
+ guard let self else {
302
+ resolve(["success": false, "sessionId": "", "error": "Instance released"])
303
+ return
304
+ }
305
+
306
+ self.stateLock.lock()
307
+ if case .active(let sid, _) = self.state {
308
+ self.stateLock.unlock()
309
+ resolve(["success": true, "sessionId": sid])
310
+ return
311
+ }
312
+ self.stateLock.unlock()
313
+
314
+ if !userId.isEmpty && userId != "anonymous" && !userId.hasPrefix("anon_") {
315
+ self.currentUserIdentity = userId
316
+ }
317
+
318
+ // Store config for session restart after background timeout
319
+ self.lastSessionConfig = config
320
+ self.lastApiUrl = apiUrl
321
+ self.lastPublicKey = publicKey
322
+
323
+ TelemetryPipeline.shared.endpoint = apiUrl
324
+ SegmentDispatcher.shared.endpoint = apiUrl
325
+ DeviceRegistrar.shared.endpoint = apiUrl
326
+
327
+ ReplayOrchestrator.shared.beginReplay(apiToken: publicKey, serverEndpoint: apiUrl, captureSettings: config)
328
+
329
+ // Allow orchestrator time to spin up
330
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
331
+ let sid = ReplayOrchestrator.shared.replayId ?? UUID().uuidString
332
+ let start = Date().timeIntervalSince1970
333
+
334
+ self.stateLock.lock()
335
+ self.state = .active(sessionId: sid, startTime: start)
336
+ self.stateLock.unlock()
337
+
338
+ ReplayOrchestrator.shared.activateGestureRecording()
339
+
340
+ if userId != "anonymous" && !userId.hasPrefix("anon_") {
341
+ ReplayOrchestrator.shared.associateUser(userId)
342
+ }
343
+
344
+ DiagnosticLog.replayBegan(sid)
345
+ resolve(["success": true, "sessionId": sid])
346
+ }
347
+ }
348
+ }
349
+
350
+ @objc(stopSession:reject:)
351
+ public func stopSession(
352
+ resolve: @escaping RCTPromiseResolveBlock,
353
+ reject: @escaping RCTPromiseRejectBlock
354
+ ) {
355
+ DispatchQueue.main.async { [weak self] in
356
+ guard let self else { return }
357
+
358
+ var targetSid = ""
359
+
360
+ self.stateLock.lock()
361
+ if case .active(let sid, _) = self.state {
362
+ targetSid = sid
363
+ }
364
+ self.state = .idle
365
+ self.stateLock.unlock()
366
+
367
+ guard !targetSid.isEmpty else {
368
+ resolve(["success": true, "sessionId": "", "uploadSuccess": true])
369
+ return
370
+ }
371
+
372
+ ReplayOrchestrator.shared.endReplay { success, uploaded in
373
+ DiagnosticLog.replayEnded(targetSid)
374
+
375
+ resolve([
376
+ "success": success,
377
+ "sessionId": targetSid,
378
+ "uploadSuccess": uploaded
379
+ ])
380
+ }
381
+ }
382
+ }
383
+
384
+ @objc(getSessionId:reject:)
385
+ public func getSessionId(
386
+ resolve: @escaping RCTPromiseResolveBlock,
387
+ reject: @escaping RCTPromiseRejectBlock
388
+ ) {
389
+ stateLock.lock()
390
+ defer { stateLock.unlock() }
391
+
392
+ switch state {
393
+ case .active(let sid, _), .paused(let sid, _):
394
+ resolve(sid)
395
+ default:
396
+ resolve(nil)
397
+ }
398
+ }
399
+
400
+ @objc(setUserIdentity:resolve:reject:)
401
+ public func setUserIdentity(
402
+ _ userId: String,
403
+ resolve: @escaping RCTPromiseResolveBlock,
404
+ reject: @escaping RCTPromiseRejectBlock
405
+ ) {
406
+ if !userId.isEmpty && userId != "anonymous" && !userId.hasPrefix("anon_") {
407
+ currentUserIdentity = userId
408
+
409
+ // Persist natively
410
+ UserDefaults.standard.set(userId, forKey: userIdentityKey)
411
+ DiagnosticLog.notice("[Rejourney] Persisted user identity: \(userId)")
412
+
413
+ ReplayOrchestrator.shared.associateUser(userId)
414
+ } else if userId == "anonymous" || userId.isEmpty {
415
+ // Clear identity
416
+ currentUserIdentity = nil
417
+ UserDefaults.standard.removeObject(forKey: userIdentityKey)
418
+ DiagnosticLog.notice("[Rejourney] Cleared user identity")
419
+ }
420
+
421
+ resolve(["success": true])
422
+ }
423
+
424
+ @objc(getUserIdentity:reject:)
425
+ public func getUserIdentity(
426
+ resolve: @escaping RCTPromiseResolveBlock,
427
+ reject: @escaping RCTPromiseRejectBlock
428
+ ) {
429
+ resolve(currentUserIdentity)
430
+ }
431
+
432
+ @objc(logEvent:details:resolve:reject:)
433
+ public func logEvent(
434
+ _ eventType: String,
435
+ details: NSDictionary,
436
+ resolve: @escaping RCTPromiseResolveBlock,
437
+ reject: @escaping RCTPromiseRejectBlock
438
+ ) {
439
+ // Handle network_request events specially to preserve type for backend metrics
440
+ if eventType == "network_request" {
441
+ // Convert NSDictionary to Swift dictionary for network event encoding
442
+ if let detailsDict = details as? [String: Any] {
443
+ TelemetryPipeline.shared.recordNetworkEvent(details: detailsDict)
444
+ }
445
+ resolve(["success": true])
446
+ return
447
+ }
448
+
449
+ // Handle JS error events - route through TelemetryPipeline as type:"error"
450
+ // so the backend ingest worker processes them into the errors table
451
+ if eventType == "error" {
452
+ let message = details["message"] as? String ?? "Unknown error"
453
+ let name = details["name"] as? String ?? "Error"
454
+ let stack = details["stack"] as? String
455
+ TelemetryPipeline.shared.recordJSErrorEvent(name: name, message: message, stack: stack)
456
+ resolve(["success": true])
457
+ return
458
+ }
459
+
460
+ // Handle dead_tap events from JS-side detection
461
+ // Native view hierarchy inspection is unreliable in React Native,
462
+ // so dead tap detection runs in JS and reports back via logEvent.
463
+ if eventType == "dead_tap" {
464
+ let x = (details["x"] as? NSNumber)?.uint64Value ?? 0
465
+ let y = (details["y"] as? NSNumber)?.uint64Value ?? 0
466
+ let label = details["label"] as? String ?? "unknown"
467
+ TelemetryPipeline.shared.recordDeadTapEvent(label: label, x: x, y: y)
468
+ ReplayOrchestrator.shared.incrementDeadTapTally()
469
+ resolve(["success": true])
470
+ return
471
+ }
472
+
473
+ // All other events go through custom event recording
474
+ var payload = "{}"
475
+ if let data = try? JSONSerialization.data(withJSONObject: details),
476
+ let str = String(data: data, encoding: .utf8) {
477
+ payload = str
478
+ }
479
+ ReplayOrchestrator.shared.recordCustomEvent(name: eventType, payload: payload)
480
+ resolve(["success": true])
481
+ }
482
+
483
+ @objc(screenChanged:resolve:reject:)
484
+ public func screenChanged(
485
+ _ screenName: String,
486
+ resolve: @escaping RCTPromiseResolveBlock,
487
+ reject: @escaping RCTPromiseRejectBlock
488
+ ) {
489
+ TelemetryPipeline.shared.recordViewTransition(viewId: screenName, viewLabel: screenName, entering: true)
490
+ ReplayOrchestrator.shared.logScreenView(screenName)
491
+ resolve(["success": true])
492
+ }
493
+
494
+ @objc(onScroll:resolve:reject:)
495
+ public func onScroll(
496
+ _ offsetY: Double,
497
+ resolve: @escaping RCTPromiseResolveBlock,
498
+ reject: @escaping RCTPromiseRejectBlock
499
+ ) {
500
+ ReplayOrchestrator.shared.logScrollAction()
501
+ resolve(["success": true])
502
+ }
503
+
504
+ @objc(markVisualChange:importance:resolve:reject:)
505
+ public func markVisualChange(
506
+ _ reason: String,
507
+ importance: String,
508
+ resolve: @escaping RCTPromiseResolveBlock,
509
+ reject: @escaping RCTPromiseRejectBlock
510
+ ) {
511
+ if importance == "high" {
512
+ VisualCapture.shared.snapshotNow()
513
+ }
514
+ resolve(true)
515
+ }
516
+
517
+ @objc(onExternalURLOpened:resolve:reject:)
518
+ public func onExternalURLOpened(
519
+ _ urlScheme: String,
520
+ resolve: @escaping RCTPromiseResolveBlock,
521
+ reject: @escaping RCTPromiseRejectBlock
522
+ ) {
523
+ ReplayOrchestrator.shared.recordCustomEvent(name: "external_url_opened", payload: "{\"scheme\":\"\(urlScheme)\"}")
524
+ resolve(["success": true])
525
+ }
526
+
527
+ @objc(onOAuthStarted:resolve:reject:)
528
+ public func onOAuthStarted(
529
+ _ provider: String,
530
+ resolve: @escaping RCTPromiseResolveBlock,
531
+ reject: @escaping RCTPromiseRejectBlock
532
+ ) {
533
+ ReplayOrchestrator.shared.recordCustomEvent(name: "oauth_started", payload: "{\"provider\":\"\(provider)\"}")
534
+ resolve(["success": true])
535
+ }
536
+
537
+ @objc(onOAuthCompleted:success:resolve:reject:)
538
+ public func onOAuthCompleted(
539
+ _ provider: String,
540
+ success: Bool,
541
+ resolve: @escaping RCTPromiseResolveBlock,
542
+ reject: @escaping RCTPromiseRejectBlock
543
+ ) {
544
+ ReplayOrchestrator.shared.recordCustomEvent(name: "oauth_completed", payload: "{\"provider\":\"\(provider)\",\"success\":\(success)}")
545
+ resolve(["success": true])
546
+ }
547
+
548
+ @objc(maskViewByNativeID:resolve:reject:)
549
+ public func maskViewByNativeID(
550
+ _ nativeID: String,
551
+ resolve: @escaping RCTPromiseResolveBlock,
552
+ reject: @escaping RCTPromiseRejectBlock
553
+ ) {
554
+ DispatchQueue.main.async {
555
+ if let target = self.findView(by: nativeID) {
556
+ ReplayOrchestrator.shared.redactView(target)
557
+ }
558
+ }
559
+ resolve(["success": true])
560
+ }
561
+
562
+ @objc(unmaskViewByNativeID:resolve:reject:)
563
+ public func unmaskViewByNativeID(
564
+ _ nativeID: String,
565
+ resolve: @escaping RCTPromiseResolveBlock,
566
+ reject: @escaping RCTPromiseRejectBlock
567
+ ) {
568
+ DispatchQueue.main.async {
569
+ if let target = self.findView(by: nativeID) {
570
+ ReplayOrchestrator.shared.unredactView(target)
571
+ }
572
+ }
573
+ resolve(["success": true])
574
+ }
575
+
576
+ private func findView(by identifier: String) -> UIView? {
577
+ guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return nil }
578
+ return scanView(window, id: identifier)
579
+ }
580
+
581
+ private func scanView(_ node: UIView, id: String) -> UIView? {
582
+ if node.accessibilityIdentifier == id || node.nativeID == id {
583
+ return node
584
+ }
585
+ for child in node.subviews {
586
+ if let match = scanView(child, id: id) {
587
+ return match
588
+ }
589
+ }
590
+ return nil
591
+ }
592
+
593
+ @objc(setDebugMode:resolve:reject:)
594
+ public func setDebugMode(
595
+ _ enabled: Bool,
596
+ resolve: @escaping RCTPromiseResolveBlock,
597
+ reject: @escaping RCTPromiseRejectBlock
598
+ ) {
599
+ DiagnosticLog.setVerbose(enabled)
600
+ resolve(["success": true])
601
+ }
602
+
603
+ @objc(setRemoteConfigWithRejourneyEnabled:recordingEnabled:sampleRate:maxRecordingMinutes:resolve:reject:)
604
+ public func setRemoteConfig(
605
+ rejourneyEnabled: Bool,
606
+ recordingEnabled: Bool,
607
+ sampleRate: Int,
608
+ maxRecordingMinutes: Int,
609
+ resolve: @escaping RCTPromiseResolveBlock,
610
+ reject: @escaping RCTPromiseRejectBlock
611
+ ) {
612
+ DiagnosticLog.notice("[Rejourney] setRemoteConfig: rejourneyEnabled=\(rejourneyEnabled), recordingEnabled=\(recordingEnabled), sampleRate=\(sampleRate), maxRecording=\(maxRecordingMinutes)min")
613
+
614
+ ReplayOrchestrator.shared.setRemoteConfig(
615
+ rejourneyEnabled: rejourneyEnabled,
616
+ recordingEnabled: recordingEnabled,
617
+ sampleRate: sampleRate,
618
+ maxRecordingMinutes: maxRecordingMinutes
619
+ )
620
+
621
+ resolve(["success": true])
622
+ }
623
+
624
+ @objc(setSDKVersion:)
625
+ public func setSDKVersion(_ version: String) {
626
+ RejourneyImpl.sdkVersion = version
627
+ }
628
+
629
+ @objc(getSDKMetrics:reject:)
630
+ public func getSDKMetrics(
631
+ resolve: @escaping RCTPromiseResolveBlock,
632
+ reject: @escaping RCTPromiseRejectBlock
633
+ ) {
634
+ resolve([
635
+ "uploadSuccessCount": 0,
636
+ "uploadFailureCount": 0,
637
+ "retryAttemptCount": 0,
638
+ "circuitBreakerOpenCount": 0,
639
+ "memoryEvictionCount": 0,
640
+ "offlinePersistCount": 0,
641
+ "sessionStartCount": (ReplayOrchestrator.shared.replayId != nil) ? 1 : 0,
642
+ "crashCount": 0,
643
+ "uploadSuccessRate": 1.0,
644
+ "avgUploadDurationMs": 0.0,
645
+ "currentQueueDepth": 0,
646
+ "lastUploadTime": NSNull(),
647
+ "lastRetryTime": NSNull(),
648
+ "totalBytesUploaded": 0,
649
+ "totalBytesEvicted": 0
650
+ ])
651
+ }
652
+
653
+ @objc(getDeviceInfo:reject:)
654
+ public func getDeviceInfo(
655
+ resolve: @escaping RCTPromiseResolveBlock,
656
+ reject: @escaping RCTPromiseRejectBlock
657
+ ) {
658
+ let device = UIDevice.current
659
+ let screen = UIScreen.main
660
+
661
+ resolve([
662
+ "platform": "ios",
663
+ "osVersion": device.systemVersion,
664
+ "model": device.model,
665
+ "deviceName": device.name,
666
+ "screenWidth": Int(screen.bounds.width * screen.scale),
667
+ "screenHeight": Int(screen.bounds.height * screen.scale),
668
+ "screenScale": screen.scale,
669
+ "deviceHash": computeHash(),
670
+ "bundleId": Bundle.main.bundleIdentifier ?? "unknown"
671
+ ])
672
+ }
673
+
674
+ @objc(debugCrash)
675
+ public func debugCrash() {
676
+ DispatchQueue.main.async {
677
+ let arr: [Int] = []
678
+ _ = arr[1]
679
+ }
680
+ }
681
+
682
+ @objc(debugTriggerANR:)
683
+ public func debugTriggerANR(_ durationMs: Double) {
684
+ DispatchQueue.main.async {
685
+ Thread.sleep(forTimeInterval: durationMs / 1000.0)
686
+ }
687
+ }
688
+
689
+ @objc(getSDKVersion:reject:)
690
+ public func getSDKVersion(
691
+ resolve: @escaping RCTPromiseResolveBlock,
692
+ reject: @escaping RCTPromiseRejectBlock
693
+ ) {
694
+ resolve(Self.sdkVersion)
695
+ }
696
+
697
+ @objc(setUserData:value:resolve:reject:)
698
+ public func setUserData(
699
+ _ key: String,
700
+ value: String,
701
+ resolve: @escaping RCTPromiseResolveBlock,
702
+ reject: @escaping RCTPromiseRejectBlock
703
+ ) {
704
+ ReplayOrchestrator.shared.attachAttribute(key: key, value: value)
705
+ resolve(nil)
706
+ }
707
+
708
+ private func computeHash() -> String {
709
+ let uuid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
710
+ guard let data = uuid.data(using: .utf8) else { return "" }
711
+
712
+ var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
713
+ data.withUnsafeBytes {
714
+ _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer)
715
+ }
716
+
717
+ return buffer.map { String(format: "%02x", $0) }.joined()
718
+ }
719
+ }