@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,860 @@
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
+ /**
18
+ * Shared implementation for Rejourney React Native module.
19
+ *
20
+ * Architecture aligned with iOS RejourneyImpl.swift
21
+ * Uses the new recording/engine/utility package structure.
22
+ */
23
+ package com.rejourney
24
+
25
+ import android.app.Activity
26
+ import android.app.Application
27
+ import android.content.Context
28
+ import android.content.Intent
29
+ import android.os.Build
30
+ import android.os.Bundle
31
+ import android.os.Handler
32
+ import android.os.Looper
33
+ import android.provider.Settings
34
+ import android.view.View
35
+ import android.view.ViewGroup
36
+ import androidx.lifecycle.DefaultLifecycleObserver
37
+ import androidx.lifecycle.LifecycleOwner
38
+ import androidx.lifecycle.ProcessLifecycleOwner
39
+ import com.facebook.react.bridge.*
40
+ import com.rejourney.engine.DeviceRegistrar
41
+ import com.rejourney.engine.DiagnosticLog
42
+ import com.rejourney.platform.OEMDetector
43
+ import com.rejourney.platform.SessionLifecycleService
44
+ import com.rejourney.platform.TaskRemovedListener
45
+ import com.rejourney.recording.*
46
+ import kotlinx.coroutines.*
47
+ import java.security.MessageDigest
48
+ import java.util.concurrent.locks.ReentrantLock
49
+ import kotlin.concurrent.withLock
50
+
51
+ /**
52
+ * Session state machine
53
+ */
54
+ sealed class SessionState {
55
+ object Idle : SessionState()
56
+ data class Active(val sessionId: String, val startTimeMs: Long) : SessionState()
57
+ data class Paused(val sessionId: String, val startTimeMs: Long) : SessionState()
58
+ object Terminated : SessionState()
59
+ }
60
+
61
+ class RejourneyModuleImpl(
62
+ private val reactContext: ReactApplicationContext,
63
+ private val isNewArchitecture: Boolean
64
+ ) : Application.ActivityLifecycleCallbacks, DefaultLifecycleObserver {
65
+
66
+ companion object {
67
+ const val NAME = "Rejourney"
68
+ var sdkVersion = "1.0.1"
69
+
70
+ private const val SESSION_TIMEOUT_MS = 60_000L // 60 seconds
71
+
72
+ private const val PREFS_NAME = "com.rejourney.prefs"
73
+ private const val KEY_USER_IDENTITY = "user_identity"
74
+ }
75
+
76
+ // State machine
77
+ private var state: SessionState = SessionState.Idle
78
+ private val stateLock = ReentrantLock()
79
+
80
+ // Internal storage
81
+ private var currentUserIdentity: String? = null
82
+ private var backgroundEntryTimeMs: Long? = null
83
+ private var lastSessionConfig: Map<String, Any>? = null
84
+ private var lastApiUrl: String? = null
85
+ private var lastPublicKey: String? = null
86
+
87
+ private val mainHandler = Handler(Looper.getMainLooper())
88
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
89
+ private val backgroundScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
90
+
91
+ @Volatile
92
+ private var isInitialized = false
93
+ private val initLock = Any()
94
+
95
+ @Volatile
96
+ private var isShuttingDown = false
97
+
98
+ init {
99
+ DiagnosticLog.trace("[Rejourney] RejourneyModuleImpl constructor")
100
+ }
101
+
102
+ /**
103
+ * Lazy initialization - called on first method invocation
104
+ */
105
+ private fun ensureInitialized() {
106
+ if (isInitialized) return
107
+
108
+ synchronized(initLock) {
109
+ if (isInitialized) return
110
+
111
+ try {
112
+ // Initialize core components
113
+ DiagnosticLog.notice("[Rejourney] ensureInitialized: Creating core components...")
114
+
115
+ // Load persisted identity
116
+ try {
117
+ val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
118
+ val persistedIdentity = prefs.getString(KEY_USER_IDENTITY, null)
119
+ if (!persistedIdentity.isNullOrBlank()) {
120
+ currentUserIdentity = persistedIdentity
121
+ DiagnosticLog.notice("[Rejourney] Restored persisted user identity: $persistedIdentity")
122
+ }
123
+ } catch (e: Exception) {
124
+ DiagnosticLog.fault("[Rejourney] Failed to load persisted identity: ${e.message}")
125
+ }
126
+
127
+ DeviceRegistrar.getInstance(reactContext)
128
+ DiagnosticLog.notice("[Rejourney] ensureInitialized: DeviceRegistrar OK")
129
+ SegmentDispatcher.shared // Uses lazy singleton
130
+ TelemetryPipeline.getInstance(reactContext)
131
+ DiagnosticLog.notice("[Rejourney] ensureInitialized: TelemetryPipeline OK")
132
+ ReplayOrchestrator.getInstance(reactContext)
133
+ DiagnosticLog.notice("[Rejourney] ensureInitialized: ReplayOrchestrator OK")
134
+ VisualCapture.getInstance(reactContext)
135
+ DiagnosticLog.notice("[Rejourney] ensureInitialized: VisualCapture OK, shared=${VisualCapture.shared != null}")
136
+ EventBuffer.getInstance(reactContext)
137
+ InteractionRecorder.getInstance(reactContext)
138
+ ViewHierarchyScanner.shared // Uses lazy singleton
139
+ StabilityMonitor.getInstance(reactContext)
140
+ AnrSentinel.shared
141
+
142
+ // Register lifecycle callbacks
143
+ registerActivityLifecycleCallbacks()
144
+ registerProcessLifecycleObserver()
145
+
146
+ // Transmit any stored crash reports
147
+ StabilityMonitor.getInstance(reactContext).transmitStoredReport()
148
+
149
+ // Android-specific: OEM detection and task removed handling
150
+ setupOEMSpecificHandling()
151
+
152
+ DiagnosticLog.notice("[Rejourney] SDK initialized (version: $sdkVersion)")
153
+ isInitialized = true
154
+
155
+ } catch (e: Exception) {
156
+ DiagnosticLog.fault("[Rejourney] Init failed: ${e.message}")
157
+ isInitialized = true // Mark as initialized to prevent retry loops
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Android-specific: Set up OEM-aware task removal detection
164
+ * Different Android OEMs have different behaviors for app lifecycle
165
+ */
166
+ private fun setupOEMSpecificHandling() {
167
+ val oem = OEMDetector.getOEM()
168
+ DiagnosticLog.trace("[Rejourney] Device OEM: $oem")
169
+ DiagnosticLog.trace("[Rejourney] OEM Recommendations: ${OEMDetector.getRecommendations()}")
170
+ DiagnosticLog.trace("[Rejourney] onTaskRemoved() reliable: ${OEMDetector.isTaskRemovedReliable()}")
171
+
172
+ try {
173
+ SessionLifecycleService.taskRemovedListener = object : TaskRemovedListener {
174
+ override fun onTaskRemoved() {
175
+ DiagnosticLog.notice("[Rejourney] App terminated via swipe-away (OEM: $oem)")
176
+ // CRITICAL: Do NOT attempt synchronous network calls here.
177
+ // It causes ANRs. The session recovery will handle on next launch.
178
+ }
179
+ }
180
+ } catch (e: Exception) {
181
+ DiagnosticLog.fault("[Rejourney] Failed to set up task removed listener: ${e.message}")
182
+ }
183
+ }
184
+
185
+ private fun registerActivityLifecycleCallbacks() {
186
+ try {
187
+ val application = reactContext.applicationContext as? Application
188
+ application?.registerActivityLifecycleCallbacks(this)
189
+ } catch (e: Exception) {
190
+ DiagnosticLog.fault("[Rejourney] Failed to register activity callbacks: ${e.message}")
191
+ }
192
+ }
193
+
194
+ private fun registerProcessLifecycleObserver() {
195
+ mainHandler.post {
196
+ try {
197
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
198
+ } catch (e: Exception) {
199
+ DiagnosticLog.fault("[Rejourney] Failed to register lifecycle observer: ${e.message}")
200
+ }
201
+ }
202
+ }
203
+
204
+ // MARK: - Lifecycle Handlers
205
+
206
+ override fun onStop(owner: LifecycleOwner) {
207
+ handleBackgrounding()
208
+ }
209
+
210
+ override fun onStart(owner: LifecycleOwner) {
211
+ handleForegrounding()
212
+ }
213
+
214
+ private fun handleBackgrounding() {
215
+ stateLock.withLock {
216
+ val currentState = state
217
+ if (currentState is SessionState.Active) {
218
+ state = SessionState.Paused(currentState.sessionId, currentState.startTimeMs)
219
+ backgroundEntryTimeMs = System.currentTimeMillis()
220
+ DiagnosticLog.notice("[Rejourney] ⏸️ Session '${currentState.sessionId}' paused (app backgrounded)")
221
+
222
+ // Flush pending data
223
+ TelemetryPipeline.shared?.dispatchNow()
224
+ SegmentDispatcher.shared.shipPending()
225
+ }
226
+ }
227
+ }
228
+
229
+ private fun handleForegrounding() {
230
+ mainHandler.post { processForegrounding() }
231
+ }
232
+
233
+ private fun processForegrounding() {
234
+ stateLock.withLock {
235
+ val currentState = state
236
+ if (currentState !is SessionState.Paused) {
237
+ DiagnosticLog.trace("[Rejourney] Foreground: not in paused state, ignoring")
238
+ return
239
+ }
240
+
241
+ val backgroundDuration = backgroundEntryTimeMs?.let {
242
+ System.currentTimeMillis() - it
243
+ } ?: 0L
244
+ backgroundEntryTimeMs = null
245
+
246
+ DiagnosticLog.notice("[Rejourney] App foregrounded after ${backgroundDuration / 1000}s (timeout: ${SESSION_TIMEOUT_MS / 1000}s)")
247
+
248
+ if (backgroundDuration > SESSION_TIMEOUT_MS) {
249
+ // End current session and start a new one
250
+ state = SessionState.Idle
251
+ val oldSessionId = currentState.sessionId
252
+
253
+ DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '$oldSessionId' and creating new one")
254
+
255
+ backgroundScope.launch {
256
+ ReplayOrchestrator.shared?.endReplay { success, uploaded ->
257
+ DiagnosticLog.notice("[Rejourney] Old session ended (success: $success, uploaded: $uploaded)")
258
+ mainHandler.post { startNewSessionAfterTimeout() }
259
+ }
260
+ }
261
+ } else {
262
+ // Resume existing session
263
+ state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
264
+ DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
265
+
266
+ // Record foreground event
267
+ TelemetryPipeline.shared?.recordAppForeground(backgroundDuration)
268
+ StabilityMonitor.shared?.transmitStoredReport()
269
+ }
270
+ }
271
+ }
272
+
273
+ private fun startNewSessionAfterTimeout() {
274
+ val apiUrl = lastApiUrl ?: return
275
+ val publicKey = lastPublicKey ?: return
276
+ val savedUserId = currentUserIdentity
277
+
278
+ DiagnosticLog.notice("[Rejourney] Starting new session after timeout (user: $savedUserId)")
279
+
280
+ // Try fast path with cached credentials
281
+ val existingCred = DeviceRegistrar.shared?.uploadCredential
282
+ if (existingCred != null && DeviceRegistrar.shared?.credentialValid == true) {
283
+ DiagnosticLog.notice("[Rejourney] Using cached credentials for fast session restart")
284
+ ReplayOrchestrator.shared?.beginReplayFast(
285
+ apiToken = publicKey,
286
+ serverEndpoint = apiUrl,
287
+ credential = existingCred,
288
+ captureSettings = lastSessionConfig
289
+ )
290
+ } else {
291
+ DiagnosticLog.notice("[Rejourney] No cached credentials, doing full session start")
292
+ ReplayOrchestrator.shared?.beginReplay(
293
+ apiToken = publicKey,
294
+ serverEndpoint = apiUrl,
295
+ captureSettings = lastSessionConfig
296
+ )
297
+ }
298
+
299
+ // Poll for session ready
300
+ waitForSessionReady(savedUserId, 0)
301
+ }
302
+
303
+ private fun waitForSessionReady(savedUserId: String?, attempts: Int) {
304
+ val maxAttempts = 30 // 3 seconds max
305
+
306
+ mainHandler.postDelayed({
307
+ val newSid = ReplayOrchestrator.shared?.replayId
308
+ if (!newSid.isNullOrEmpty()) {
309
+ stateLock.withLock {
310
+ state = SessionState.Active(newSid, System.currentTimeMillis())
311
+ }
312
+
313
+ ReplayOrchestrator.shared?.activateGestureRecording()
314
+
315
+ // Restore user identity
316
+ if (!savedUserId.isNullOrBlank() && savedUserId != "anonymous" && !savedUserId.startsWith("anon_")) {
317
+ ReplayOrchestrator.shared?.associateUser(savedUserId)
318
+ DiagnosticLog.notice("[Rejourney] ✅ Restored user identity '$savedUserId' to new session $newSid")
319
+ }
320
+
321
+ DiagnosticLog.replayBegan(newSid)
322
+ DiagnosticLog.notice("[Rejourney] ✅ New session started: $newSid")
323
+ } else if (attempts < maxAttempts) {
324
+ waitForSessionReady(savedUserId, attempts + 1)
325
+ } else {
326
+ DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
327
+ }
328
+ }, 100)
329
+ }
330
+
331
+ // MARK: - Public API
332
+
333
+ fun startSession(userId: String, apiUrl: String, publicKey: String, promise: Promise) {
334
+ startSessionWithOptions(
335
+ Arguments.createMap().apply {
336
+ putString("userId", userId)
337
+ putString("apiUrl", apiUrl)
338
+ putString("publicKey", publicKey)
339
+ },
340
+ promise
341
+ )
342
+ }
343
+
344
+ fun startSessionWithOptions(options: ReadableMap, promise: Promise) {
345
+ ensureInitialized()
346
+
347
+ if (isShuttingDown) {
348
+ promise.resolve(createResultMap(false, "", "Module is shutting down"))
349
+ return
350
+ }
351
+
352
+ val debug = options.getBooleanSafe("debug", false)
353
+ if (debug) {
354
+ DiagnosticLog.setVerbose(true)
355
+ DiagnosticLog.notice("[Rejourney] Debug mode ENABLED - verbose logging active")
356
+ }
357
+
358
+ val userId = options.getStringSafe("userId", "anonymous")
359
+ val apiUrl = options.getStringSafe("apiUrl", "https://api.rejourney.co")
360
+ val publicKey = options.getStringSafe("publicKey", "")
361
+
362
+ if (publicKey.isEmpty()) {
363
+ promise.reject("INVALID_KEY", "publicKey is required")
364
+ return
365
+ }
366
+
367
+ // Build config from options
368
+ val config = mutableMapOf<String, Any>()
369
+ if (options.hasKey("captureScreen")) config["captureScreen"] = options.getBoolean("captureScreen")
370
+ if (options.hasKey("captureAnalytics")) config["captureAnalytics"] = options.getBoolean("captureAnalytics")
371
+ if (options.hasKey("captureCrashes")) config["captureCrashes"] = options.getBoolean("captureCrashes")
372
+ if (options.hasKey("captureANR")) config["captureANR"] = options.getBoolean("captureANR")
373
+ if (options.hasKey("wifiOnly")) config["wifiOnly"] = options.getBoolean("wifiOnly")
374
+
375
+ if (options.hasKey("fps")) {
376
+ val fps = options.getInt("fps").coerceIn(1, 30)
377
+ config["captureRate"] = 1.0 / fps
378
+ }
379
+
380
+ if (options.hasKey("quality")) {
381
+ when (options.getString("quality")?.lowercase()) {
382
+ "low" -> config["imgCompression"] = 0.4
383
+ "high" -> config["imgCompression"] = 0.7
384
+ else -> config["imgCompression"] = 0.5
385
+ }
386
+ }
387
+
388
+ mainHandler.post {
389
+ // Check if already active
390
+ stateLock.withLock {
391
+ val currentState = state
392
+ if (currentState is SessionState.Active) {
393
+ promise.resolve(createResultMap(true, currentState.sessionId))
394
+ return@post
395
+ }
396
+ }
397
+
398
+ if (!userId.isNullOrBlank() && userId != "anonymous" && !userId.startsWith("anon_")) {
399
+ currentUserIdentity = userId
400
+ }
401
+
402
+ // Store for session restart
403
+ lastSessionConfig = config
404
+ lastApiUrl = apiUrl
405
+ lastPublicKey = publicKey
406
+
407
+ // Configure endpoints and tokens
408
+ TelemetryPipeline.shared?.endpoint = apiUrl
409
+ TelemetryPipeline.shared?.apiToken = publicKey
410
+ SegmentDispatcher.shared.endpoint = apiUrl
411
+ DeviceRegistrar.shared?.endpoint = apiUrl
412
+
413
+ // Set current activity on capture components before starting
414
+ val activity = reactContext.currentActivity
415
+ DiagnosticLog.notice("[Rejourney] startSession: currentActivity=${activity?.javaClass?.simpleName ?: "NULL"}, VisualCapture.shared=${VisualCapture.shared != null}")
416
+ if (activity != null) {
417
+ DiagnosticLog.notice("[Rejourney] Setting activity on capture components")
418
+ VisualCapture.shared?.setCurrentActivity(activity)
419
+ ViewHierarchyScanner.shared?.setCurrentActivity(activity)
420
+ InteractionRecorder.shared?.setCurrentActivity(activity)
421
+ DiagnosticLog.notice("[Rejourney] Activity set on all components")
422
+ } else {
423
+ DiagnosticLog.fault("[Rejourney] CRITICAL: No current activity available for capture!")
424
+ }
425
+
426
+ // Pre-generate session ID to ensure consistency between JS and native
427
+ val sid = "session_${System.currentTimeMillis()}_${java.util.UUID.randomUUID().toString().replace("-", "").lowercase()}"
428
+ ReplayOrchestrator.shared?.replayId = sid
429
+ TelemetryPipeline.shared?.currentReplayId = sid
430
+
431
+ // Begin replay
432
+ ReplayOrchestrator.shared?.beginReplay(
433
+ apiToken = publicKey,
434
+ serverEndpoint = apiUrl,
435
+ captureSettings = config
436
+ )
437
+
438
+ // Android-specific: Start SessionLifecycleService for task removal detection
439
+ startSessionLifecycleService()
440
+
441
+ // Allow orchestrator time to spin up
442
+ mainHandler.postDelayed({
443
+ stateLock.withLock {
444
+ state = SessionState.Active(sid, System.currentTimeMillis())
445
+ }
446
+
447
+ ReplayOrchestrator.shared?.activateGestureRecording()
448
+
449
+ if (!userId.isNullOrBlank() && userId != "anonymous" && !userId.startsWith("anon_")) {
450
+ ReplayOrchestrator.shared?.associateUser(userId)
451
+ }
452
+
453
+ DiagnosticLog.replayBegan(sid)
454
+ promise.resolve(createResultMap(true, sid))
455
+ }, 300)
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Android-specific: Start the SessionLifecycleService for task removal detection
461
+ */
462
+ private fun startSessionLifecycleService() {
463
+ try {
464
+ val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
465
+ reactContext.startService(serviceIntent)
466
+ DiagnosticLog.trace("[Rejourney] SessionLifecycleService started")
467
+ } catch (e: Exception) {
468
+ DiagnosticLog.caution("[Rejourney] Failed to start SessionLifecycleService: ${e.message}")
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Android-specific: Stop the SessionLifecycleService
474
+ */
475
+ private fun stopSessionLifecycleService() {
476
+ try {
477
+ val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
478
+ reactContext.stopService(serviceIntent)
479
+ DiagnosticLog.trace("[Rejourney] SessionLifecycleService stopped")
480
+ } catch (e: Exception) {
481
+ DiagnosticLog.caution("[Rejourney] Failed to stop SessionLifecycleService: ${e.message}")
482
+ }
483
+ }
484
+
485
+ fun stopSession(promise: Promise) {
486
+ mainHandler.post {
487
+ var targetSid = ""
488
+
489
+ stateLock.withLock {
490
+ val currentState = state
491
+ if (currentState is SessionState.Active) {
492
+ targetSid = currentState.sessionId
493
+ }
494
+ state = SessionState.Idle
495
+ }
496
+
497
+ // Android-specific: Stop SessionLifecycleService
498
+ stopSessionLifecycleService()
499
+
500
+ if (targetSid.isEmpty()) {
501
+ promise.resolve(createResultMap(true, "", uploadSuccess = true))
502
+ return@post
503
+ }
504
+
505
+ ReplayOrchestrator.shared?.endReplay { success, uploaded ->
506
+ DiagnosticLog.replayEnded(targetSid)
507
+
508
+ promise.resolve(Arguments.createMap().apply {
509
+ putBoolean("success", success)
510
+ putString("sessionId", targetSid)
511
+ putBoolean("uploadSuccess", uploaded)
512
+ })
513
+ }
514
+ }
515
+ }
516
+
517
+ fun getSessionId(promise: Promise) {
518
+ stateLock.withLock {
519
+ when (val currentState = state) {
520
+ is SessionState.Active -> promise.resolve(currentState.sessionId)
521
+ is SessionState.Paused -> promise.resolve(currentState.sessionId)
522
+ else -> promise.resolve(null)
523
+ }
524
+ }
525
+ }
526
+
527
+ fun setUserIdentity(userId: String, promise: Promise) {
528
+ if (!userId.isNullOrBlank() && userId != "anonymous" && !userId.startsWith("anon_")) {
529
+ currentUserIdentity = userId
530
+
531
+ // Persist natively
532
+ try {
533
+ val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
534
+ prefs.edit().putString(KEY_USER_IDENTITY, userId).apply()
535
+ DiagnosticLog.notice("[Rejourney] Persisted user identity: $userId")
536
+ } catch (e: Exception) {
537
+ DiagnosticLog.fault("[Rejourney] Failed to persist identity: ${e.message}")
538
+ }
539
+
540
+ ReplayOrchestrator.shared?.associateUser(userId)
541
+ } else if (userId == "anonymous" || userId.isNullOrBlank()) {
542
+ // Clear identity
543
+ currentUserIdentity = null
544
+ try {
545
+ val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
546
+ prefs.edit().remove(KEY_USER_IDENTITY).apply()
547
+ } catch (e: Exception) {}
548
+ }
549
+
550
+ promise.resolve(createResultMap(true))
551
+ }
552
+
553
+ fun getUserIdentity(promise: Promise) {
554
+ promise.resolve(currentUserIdentity)
555
+ }
556
+
557
+ fun logEvent(eventType: String, details: ReadableMap, promise: Promise) {
558
+ // Handle network_request events specially
559
+ if (eventType == "network_request") {
560
+ val detailsMap = details.toHashMap().filterValues { it != null }.mapValues { it.value!! }
561
+ TelemetryPipeline.shared?.recordNetworkEvent(detailsMap)
562
+ promise.resolve(createResultMap(true))
563
+ return
564
+ }
565
+
566
+ // Handle JS error events - route through TelemetryPipeline as type:"error"
567
+ // so the backend ingest worker processes them into the errors table
568
+ if (eventType == "error") {
569
+ val detailsMap = details.toHashMap()
570
+ val message = detailsMap["message"]?.toString() ?: "Unknown error"
571
+ val name = detailsMap["name"]?.toString() ?: "Error"
572
+ val stack = detailsMap["stack"]?.toString()
573
+ TelemetryPipeline.shared?.recordJSErrorEvent(name, message, stack)
574
+ promise.resolve(createResultMap(true))
575
+ return
576
+ }
577
+
578
+ // Handle dead_tap events from JS-side detection
579
+ // Native view hierarchy inspection is unreliable in React Native,
580
+ // so dead tap detection runs in JS and reports back via logEvent.
581
+ if (eventType == "dead_tap") {
582
+ val detailsMap = details.toHashMap()
583
+ val x = (detailsMap["x"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0L
584
+ val y = (detailsMap["y"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0L
585
+ val label = detailsMap["label"]?.toString() ?: "unknown"
586
+ TelemetryPipeline.shared?.recordDeadTapEvent(label, x, y)
587
+ ReplayOrchestrator.shared?.incrementDeadTapTally()
588
+ promise.resolve(createResultMap(true))
589
+ return
590
+ }
591
+
592
+ // All other events go through custom event recording
593
+ val payload = try {
594
+ val json = org.json.JSONObject(details.toHashMap()).toString()
595
+ json
596
+ } catch (e: Exception) {
597
+ "{}"
598
+ }
599
+
600
+ ReplayOrchestrator.shared?.recordCustomEvent(eventType, payload)
601
+ promise.resolve(createResultMap(true))
602
+ }
603
+
604
+ fun screenChanged(screenName: String, promise: Promise) {
605
+ TelemetryPipeline.shared?.recordViewTransition(screenName, screenName, true)
606
+ ReplayOrchestrator.shared?.logScreenView(screenName)
607
+ promise.resolve(createResultMap(true))
608
+ }
609
+
610
+ fun onScroll(offsetY: Double, promise: Promise) {
611
+ ReplayOrchestrator.shared?.logScrollAction()
612
+ promise.resolve(createResultMap(true))
613
+ }
614
+
615
+ fun markVisualChange(reason: String, importance: String, promise: Promise) {
616
+ if (importance == "high") {
617
+ VisualCapture.shared?.snapshotNow()
618
+ }
619
+ promise.resolve(true)
620
+ }
621
+
622
+ fun onExternalURLOpened(urlScheme: String, promise: Promise) {
623
+ ReplayOrchestrator.shared?.recordCustomEvent("external_url_opened", "{\"scheme\":\"$urlScheme\"}")
624
+ promise.resolve(createResultMap(true))
625
+ }
626
+
627
+ fun onOAuthStarted(provider: String, promise: Promise) {
628
+ ReplayOrchestrator.shared?.recordCustomEvent("oauth_started", "{\"provider\":\"$provider\"}")
629
+ promise.resolve(createResultMap(true))
630
+ }
631
+
632
+ fun onOAuthCompleted(provider: String, success: Boolean, promise: Promise) {
633
+ ReplayOrchestrator.shared?.recordCustomEvent("oauth_completed", "{\"provider\":\"$provider\",\"success\":$success}")
634
+ promise.resolve(createResultMap(true))
635
+ }
636
+
637
+ fun maskViewByNativeID(nativeID: String, promise: Promise) {
638
+ mainHandler.post {
639
+ findViewByNativeID(nativeID)?.let { view ->
640
+ ReplayOrchestrator.shared?.redactView(view)
641
+ }
642
+ }
643
+ promise.resolve(createResultMap(true))
644
+ }
645
+
646
+ fun unmaskViewByNativeID(nativeID: String, promise: Promise) {
647
+ mainHandler.post {
648
+ findViewByNativeID(nativeID)?.let { view ->
649
+ ReplayOrchestrator.shared?.unredactView(view)
650
+ }
651
+ }
652
+ promise.resolve(createResultMap(true))
653
+ }
654
+
655
+ private fun findViewByNativeID(nativeID: String): View? {
656
+ val activity = reactContext.currentActivity ?: return null
657
+ val rootView = activity.window?.decorView?.rootView as? ViewGroup ?: return null
658
+ return scanViewForNativeID(rootView, nativeID)
659
+ }
660
+
661
+ private fun scanViewForNativeID(view: View, nativeID: String): View? {
662
+ if (view.contentDescription?.toString() == nativeID) {
663
+ return view
664
+ }
665
+ if (view is ViewGroup) {
666
+ for (i in 0 until view.childCount) {
667
+ val result = scanViewForNativeID(view.getChildAt(i), nativeID)
668
+ if (result != null) return result
669
+ }
670
+ }
671
+ return null
672
+ }
673
+
674
+ fun setDebugMode(enabled: Boolean, promise: Promise) {
675
+ DiagnosticLog.setVerbose(enabled)
676
+ promise.resolve(createResultMap(true))
677
+ }
678
+
679
+ fun setRemoteConfig(
680
+ rejourneyEnabled: Boolean,
681
+ recordingEnabled: Boolean,
682
+ sampleRate: Double,
683
+ maxRecordingMinutes: Double,
684
+ promise: Promise
685
+ ) {
686
+ try {
687
+ ReplayOrchestrator.shared?.setRemoteConfig(
688
+ rejourneyEnabled = rejourneyEnabled,
689
+ recordingEnabled = recordingEnabled,
690
+ sampleRate = sampleRate.toInt(),
691
+ maxRecordingMinutes = maxRecordingMinutes.toInt()
692
+ )
693
+ DiagnosticLog.notice("[Rejourney] Remote config applied: rejourneyEnabled=$rejourneyEnabled, recordingEnabled=$recordingEnabled, sampleRate=$sampleRate%, maxRecording=${maxRecordingMinutes}min")
694
+ promise.resolve(createResultMap(true))
695
+ } catch (e: Exception) {
696
+ DiagnosticLog.fault("[Rejourney] Failed to set remote config: ${e.message}")
697
+ promise.resolve(createResultMap(false, error = "Failed to set remote config: ${e.message}"))
698
+ }
699
+ }
700
+
701
+ fun setSDKVersion(version: String) {
702
+ sdkVersion = version
703
+ }
704
+
705
+ fun getSDKVersion(promise: Promise) {
706
+ promise.resolve(sdkVersion)
707
+ }
708
+
709
+ fun getSDKMetrics(promise: Promise) {
710
+ val dispatcher = SegmentDispatcher.shared
711
+ val pipeline = TelemetryPipeline.shared
712
+
713
+ promise.resolve(Arguments.createMap().apply {
714
+ putInt("uploadSuccessCount", dispatcher.uploadSuccessCount)
715
+ putInt("uploadFailureCount", dispatcher.uploadFailureCount)
716
+ putInt("retryAttemptCount", 0)
717
+ putInt("circuitBreakerOpenCount", dispatcher.circuitBreakerOpenCount)
718
+ putInt("memoryEvictionCount", 0)
719
+ putInt("offlinePersistCount", 0)
720
+ putInt("sessionStartCount", if (ReplayOrchestrator.shared?.replayId != null) 1 else 0)
721
+ putInt("crashCount", 0)
722
+
723
+ val total = dispatcher.uploadSuccessCount + dispatcher.uploadFailureCount
724
+ putDouble("uploadSuccessRate", if (total > 0) dispatcher.uploadSuccessCount.toDouble() / total else 1.0)
725
+
726
+ putDouble("avgUploadDurationMs", 0.0)
727
+ putInt("currentQueueDepth", pipeline?.getQueueDepth() ?: 0)
728
+ putNull("lastUploadTime")
729
+ putNull("lastRetryTime")
730
+ putDouble("totalBytesUploaded", dispatcher.totalBytesUploaded.toDouble())
731
+ putInt("totalBytesEvicted", 0)
732
+ })
733
+ }
734
+
735
+ fun getDeviceInfo(promise: Promise) {
736
+ val deviceHash = computeDeviceHash()
737
+
738
+ promise.resolve(Arguments.createMap().apply {
739
+ putString("platform", "android")
740
+ putString("osVersion", Build.VERSION.RELEASE)
741
+ putString("model", Build.MODEL)
742
+ putString("brand", Build.MANUFACTURER)
743
+ putInt("screenWidth", reactContext.resources.displayMetrics.widthPixels)
744
+ putInt("screenHeight", reactContext.resources.displayMetrics.heightPixels)
745
+ putDouble("screenScale", reactContext.resources.displayMetrics.density.toDouble())
746
+ putString("deviceHash", deviceHash)
747
+ putString("bundleId", reactContext.packageName ?: "unknown")
748
+ })
749
+ }
750
+
751
+ fun debugCrash() {
752
+ mainHandler.post {
753
+ throw RuntimeException("Rejourney debug crash triggered")
754
+ }
755
+ }
756
+
757
+ fun debugTriggerANR(durationMs: Double) {
758
+ mainHandler.post {
759
+ Thread.sleep(durationMs.toLong())
760
+ }
761
+ }
762
+
763
+ fun setUserData(key: String, value: String, promise: Promise) {
764
+ ReplayOrchestrator.shared?.attachAttribute(key, value)
765
+ promise.resolve(null)
766
+ }
767
+
768
+ // MARK: - Utility Methods
769
+
770
+ private fun computeDeviceHash(): String {
771
+ val androidId = Settings.Secure.getString(
772
+ reactContext.contentResolver,
773
+ Settings.Secure.ANDROID_ID
774
+ ) ?: "unknown"
775
+
776
+ return try {
777
+ val digest = MessageDigest.getInstance("SHA-256")
778
+ val hash = digest.digest(androidId.toByteArray())
779
+ hash.joinToString("") { "%02x".format(it) }
780
+ } catch (e: Exception) {
781
+ ""
782
+ }
783
+ }
784
+
785
+ private fun createResultMap(success: Boolean, sessionId: String = "", error: String? = null, uploadSuccess: Boolean? = null): WritableMap {
786
+ return Arguments.createMap().apply {
787
+ putBoolean("success", success)
788
+ putString("sessionId", sessionId)
789
+ error?.let { putString("error", it) }
790
+ uploadSuccess?.let { putBoolean("uploadSuccess", it) }
791
+ }
792
+ }
793
+
794
+ // MARK: - Activity Lifecycle Callbacks
795
+
796
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
797
+ override fun onActivityStarted(activity: Activity) {}
798
+ override fun onActivityResumed(activity: Activity) {
799
+ // Set current activity on capture components so they can capture the screen
800
+ DiagnosticLog.notice("[Rejourney] onActivityResumed: ${activity.javaClass.simpleName}")
801
+ VisualCapture.shared?.setCurrentActivity(activity)
802
+ ViewHierarchyScanner.shared?.setCurrentActivity(activity)
803
+ InteractionRecorder.shared?.setCurrentActivity(activity)
804
+ }
805
+ override fun onActivityPaused(activity: Activity) {
806
+ // DO NOT clear activity references on pause!
807
+ // Activities can be paused during normal operation (dialogs, config changes, etc.)
808
+ // Clearing the activity here breaks screen capture during async credential fetch.
809
+ // Activity will be updated when a new activity resumes or when the app is destroyed.
810
+ DiagnosticLog.trace("[Rejourney] onActivityPaused: ${activity.javaClass.simpleName} (keeping activity reference)")
811
+ }
812
+ override fun onActivityStopped(activity: Activity) {
813
+ // Only clear when stopped (not visible) to avoid breaking capture during pause states
814
+ DiagnosticLog.trace("[Rejourney] onActivityStopped: ${activity.javaClass.simpleName}")
815
+ }
816
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
817
+ override fun onActivityDestroyed(activity: Activity) {
818
+ // Clear references only on destroy to avoid leaks
819
+ DiagnosticLog.trace("[Rejourney] onActivityDestroyed: ${activity.javaClass.simpleName}")
820
+ VisualCapture.shared?.setCurrentActivity(null)
821
+ ViewHierarchyScanner.shared?.setCurrentActivity(null)
822
+ InteractionRecorder.shared?.setCurrentActivity(null)
823
+ }
824
+
825
+ // MARK: - Event Emission (no-ops, dead tap detection is native-side)
826
+
827
+ fun addListener(eventName: String) {
828
+ // No-op: dead tap detection is handled natively in TelemetryPipeline
829
+ }
830
+
831
+ fun removeListeners(count: Double) {
832
+ // No-op: dead tap detection is handled natively in TelemetryPipeline
833
+ }
834
+
835
+ // MARK: - Cleanup
836
+
837
+ fun invalidate() {
838
+ isShuttingDown = true
839
+ scope.cancel()
840
+ backgroundScope.cancel()
841
+
842
+ val application = reactContext.applicationContext as? Application
843
+ application?.unregisterActivityLifecycleCallbacks(this)
844
+
845
+ mainHandler.post {
846
+ try {
847
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
848
+ } catch (_: Exception) {}
849
+ }
850
+ }
851
+ }
852
+
853
+ // Extension functions for safe ReadableMap access
854
+ private fun ReadableMap.getStringSafe(key: String, default: String): String {
855
+ return if (hasKey(key) && !isNull(key)) getString(key) ?: default else default
856
+ }
857
+
858
+ private fun ReadableMap.getBooleanSafe(key: String, default: Boolean): Boolean {
859
+ return if (hasKey(key) && !isNull(key)) getBoolean(key) else default
860
+ }