@rejourneyco/react-native 1.0.0

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 (152) hide show
  1. package/android/build.gradle.kts +135 -0
  2. package/android/consumer-rules.pro +10 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/main/AndroidManifest.xml +15 -0
  5. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
  6. package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
  7. package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
  8. package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
  9. package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
  10. package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
  11. package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
  12. package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
  13. package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
  14. package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
  15. package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
  16. package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
  17. package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
  18. package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
  19. package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
  20. package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
  21. package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
  22. package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
  23. package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
  24. package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
  25. package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
  26. package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
  27. package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
  28. package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
  29. package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
  30. package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
  31. package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
  32. package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
  33. package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
  34. package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
  35. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
  36. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  37. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
  38. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  39. package/ios/Capture/RJANRHandler.h +42 -0
  40. package/ios/Capture/RJANRHandler.m +328 -0
  41. package/ios/Capture/RJCaptureEngine.h +275 -0
  42. package/ios/Capture/RJCaptureEngine.m +2062 -0
  43. package/ios/Capture/RJCaptureHeuristics.h +80 -0
  44. package/ios/Capture/RJCaptureHeuristics.m +903 -0
  45. package/ios/Capture/RJCrashHandler.h +46 -0
  46. package/ios/Capture/RJCrashHandler.m +313 -0
  47. package/ios/Capture/RJMotionEvent.h +183 -0
  48. package/ios/Capture/RJMotionEvent.m +183 -0
  49. package/ios/Capture/RJPerformanceManager.h +100 -0
  50. package/ios/Capture/RJPerformanceManager.m +373 -0
  51. package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
  52. package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
  53. package/ios/Capture/RJSegmentUploader.h +146 -0
  54. package/ios/Capture/RJSegmentUploader.m +778 -0
  55. package/ios/Capture/RJVideoEncoder.h +247 -0
  56. package/ios/Capture/RJVideoEncoder.m +1036 -0
  57. package/ios/Capture/RJViewControllerTracker.h +73 -0
  58. package/ios/Capture/RJViewControllerTracker.m +508 -0
  59. package/ios/Capture/RJViewHierarchyScanner.h +215 -0
  60. package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
  61. package/ios/Capture/RJViewSerializer.h +119 -0
  62. package/ios/Capture/RJViewSerializer.m +498 -0
  63. package/ios/Core/RJConstants.h +124 -0
  64. package/ios/Core/RJConstants.m +88 -0
  65. package/ios/Core/RJLifecycleManager.h +85 -0
  66. package/ios/Core/RJLifecycleManager.m +308 -0
  67. package/ios/Core/RJLogger.h +61 -0
  68. package/ios/Core/RJLogger.m +211 -0
  69. package/ios/Core/RJTypes.h +176 -0
  70. package/ios/Core/RJTypes.m +66 -0
  71. package/ios/Core/Rejourney.h +64 -0
  72. package/ios/Core/Rejourney.mm +2495 -0
  73. package/ios/Network/RJDeviceAuthManager.h +94 -0
  74. package/ios/Network/RJDeviceAuthManager.m +967 -0
  75. package/ios/Network/RJNetworkMonitor.h +68 -0
  76. package/ios/Network/RJNetworkMonitor.m +267 -0
  77. package/ios/Network/RJRetryManager.h +73 -0
  78. package/ios/Network/RJRetryManager.m +325 -0
  79. package/ios/Network/RJUploadManager.h +267 -0
  80. package/ios/Network/RJUploadManager.m +2296 -0
  81. package/ios/Privacy/RJPrivacyMask.h +163 -0
  82. package/ios/Privacy/RJPrivacyMask.m +922 -0
  83. package/ios/Rejourney.h +63 -0
  84. package/ios/Touch/RJGestureClassifier.h +130 -0
  85. package/ios/Touch/RJGestureClassifier.m +333 -0
  86. package/ios/Touch/RJTouchInterceptor.h +169 -0
  87. package/ios/Touch/RJTouchInterceptor.m +772 -0
  88. package/ios/Utils/RJEventBuffer.h +112 -0
  89. package/ios/Utils/RJEventBuffer.m +358 -0
  90. package/ios/Utils/RJGzipUtils.h +33 -0
  91. package/ios/Utils/RJGzipUtils.m +89 -0
  92. package/ios/Utils/RJKeychainManager.h +48 -0
  93. package/ios/Utils/RJKeychainManager.m +111 -0
  94. package/ios/Utils/RJPerfTiming.h +209 -0
  95. package/ios/Utils/RJPerfTiming.m +264 -0
  96. package/ios/Utils/RJTelemetry.h +92 -0
  97. package/ios/Utils/RJTelemetry.m +320 -0
  98. package/ios/Utils/RJWindowUtils.h +66 -0
  99. package/ios/Utils/RJWindowUtils.m +133 -0
  100. package/lib/commonjs/NativeRejourney.js +40 -0
  101. package/lib/commonjs/components/Mask.js +79 -0
  102. package/lib/commonjs/index.js +1381 -0
  103. package/lib/commonjs/sdk/autoTracking.js +1259 -0
  104. package/lib/commonjs/sdk/constants.js +151 -0
  105. package/lib/commonjs/sdk/errorTracking.js +199 -0
  106. package/lib/commonjs/sdk/index.js +50 -0
  107. package/lib/commonjs/sdk/metricsTracking.js +204 -0
  108. package/lib/commonjs/sdk/navigation.js +151 -0
  109. package/lib/commonjs/sdk/networkInterceptor.js +412 -0
  110. package/lib/commonjs/sdk/utils.js +363 -0
  111. package/lib/commonjs/types/expo-router.d.js +2 -0
  112. package/lib/commonjs/types/index.js +2 -0
  113. package/lib/module/NativeRejourney.js +38 -0
  114. package/lib/module/components/Mask.js +72 -0
  115. package/lib/module/index.js +1284 -0
  116. package/lib/module/sdk/autoTracking.js +1233 -0
  117. package/lib/module/sdk/constants.js +145 -0
  118. package/lib/module/sdk/errorTracking.js +189 -0
  119. package/lib/module/sdk/index.js +12 -0
  120. package/lib/module/sdk/metricsTracking.js +187 -0
  121. package/lib/module/sdk/navigation.js +143 -0
  122. package/lib/module/sdk/networkInterceptor.js +401 -0
  123. package/lib/module/sdk/utils.js +342 -0
  124. package/lib/module/types/expo-router.d.js +2 -0
  125. package/lib/module/types/index.js +2 -0
  126. package/lib/typescript/NativeRejourney.d.ts +147 -0
  127. package/lib/typescript/components/Mask.d.ts +39 -0
  128. package/lib/typescript/index.d.ts +117 -0
  129. package/lib/typescript/sdk/autoTracking.d.ts +204 -0
  130. package/lib/typescript/sdk/constants.d.ts +120 -0
  131. package/lib/typescript/sdk/errorTracking.d.ts +32 -0
  132. package/lib/typescript/sdk/index.d.ts +9 -0
  133. package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
  134. package/lib/typescript/sdk/navigation.d.ts +33 -0
  135. package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
  136. package/lib/typescript/sdk/utils.d.ts +148 -0
  137. package/lib/typescript/types/index.d.ts +624 -0
  138. package/package.json +102 -0
  139. package/rejourney.podspec +21 -0
  140. package/src/NativeRejourney.ts +165 -0
  141. package/src/components/Mask.tsx +80 -0
  142. package/src/index.ts +1459 -0
  143. package/src/sdk/autoTracking.ts +1373 -0
  144. package/src/sdk/constants.ts +134 -0
  145. package/src/sdk/errorTracking.ts +231 -0
  146. package/src/sdk/index.ts +11 -0
  147. package/src/sdk/metricsTracking.ts +232 -0
  148. package/src/sdk/navigation.ts +157 -0
  149. package/src/sdk/networkInterceptor.ts +440 -0
  150. package/src/sdk/utils.ts +369 -0
  151. package/src/types/expo-router.d.ts +7 -0
  152. package/src/types/index.ts +739 -0
@@ -0,0 +1,2981 @@
1
+ /**
2
+ * Shared implementation for Rejourney React Native module.
3
+ *
4
+ * This class contains all the business logic shared between:
5
+ * - Old Architecture (Bridge) module
6
+ * - New Architecture (TurboModules) module
7
+ *
8
+ * The actual RejourneyModule classes in oldarch/ and newarch/ are thin wrappers
9
+ * that delegate to this implementation.
10
+ */
11
+ package com.rejourney
12
+
13
+ import android.app.Activity
14
+ import android.app.Application
15
+ import android.content.Context
16
+ import android.content.Intent
17
+ import android.os.Build
18
+ import android.os.Bundle
19
+ import android.os.Handler
20
+ import android.os.Looper
21
+ import android.view.MotionEvent
22
+ import android.provider.Settings
23
+ import androidx.lifecycle.DefaultLifecycleObserver
24
+ import androidx.lifecycle.LifecycleOwner
25
+ import androidx.lifecycle.ProcessLifecycleOwner
26
+ import com.rejourney.lifecycle.SessionLifecycleService
27
+ import com.rejourney.lifecycle.TaskRemovedListener
28
+ import com.facebook.react.bridge.*
29
+ import com.facebook.react.modules.core.DeviceEventManagerModule
30
+ import com.rejourney.capture.CaptureEngine
31
+ import com.rejourney.capture.CaptureEngineDelegate
32
+ import com.rejourney.capture.CrashHandler
33
+ import com.rejourney.capture.ANRHandler
34
+ import com.rejourney.core.Constants
35
+ import com.rejourney.core.EventType
36
+ import com.rejourney.core.Logger
37
+ import com.rejourney.core.SDKMetrics
38
+ import com.rejourney.network.AuthFailureListener
39
+ import com.rejourney.network.DeviceAuthManager
40
+ import com.rejourney.network.NetworkMonitor
41
+ import com.rejourney.network.NetworkMonitorListener
42
+ import com.rejourney.network.UploadManager
43
+ import com.rejourney.network.UploadWorker
44
+ import com.rejourney.touch.KeyboardTracker
45
+ import com.rejourney.touch.KeyboardTrackerListener
46
+ import com.rejourney.touch.TextInputTracker
47
+ import com.rejourney.touch.TextInputTrackerListener
48
+ import com.rejourney.touch.TouchInterceptor
49
+ import com.rejourney.touch.TouchInterceptorDelegate
50
+ import com.rejourney.utils.EventBuffer
51
+ import com.rejourney.utils.OEMDetector
52
+ import com.rejourney.utils.Telemetry
53
+ import com.rejourney.utils.WindowUtils
54
+ import kotlinx.coroutines.*
55
+ import java.security.MessageDigest
56
+ import java.io.File
57
+ import java.util.*
58
+ import java.util.concurrent.CopyOnWriteArrayList
59
+
60
+ // Enum for session end reasons
61
+ enum class EndReason {
62
+ SESSION_TIMEOUT,
63
+ MANUAL_STOP,
64
+ DURATION_LIMIT,
65
+ REMOTE_DISABLE
66
+ }
67
+
68
+ class RejourneyModuleImpl(
69
+ private val reactContext: ReactApplicationContext,
70
+ private val isNewArchitecture: Boolean
71
+ ) : Application.ActivityLifecycleCallbacks,
72
+ TouchInterceptorDelegate,
73
+ NetworkMonitorListener,
74
+ DefaultLifecycleObserver,
75
+ KeyboardTrackerListener,
76
+ TextInputTrackerListener,
77
+ ANRHandler.ANRListener,
78
+ CaptureEngineDelegate,
79
+ AuthFailureListener {
80
+
81
+ companion object {
82
+ const val NAME = "Rejourney"
83
+ const val BACKGROUND_RESUME_TIMEOUT_MS = 30_000L // 30 seconds
84
+
85
+ // Auth retry constants
86
+ private const val MAX_AUTH_RETRIES = 5
87
+ private const val AUTH_RETRY_BASE_DELAY_MS = 2000L // 2 seconds base
88
+ private const val AUTH_RETRY_MAX_DELAY_MS = 60000L // 1 minute max
89
+ private const val AUTH_BACKGROUND_RETRY_DELAY_MS = 300000L // 5 minutes
90
+
91
+ // Store process start time at class load for accurate app startup measurement
92
+ @JvmStatic
93
+ private val processStartTimeMs: Long = run {
94
+ try {
95
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
96
+ // API 24+: Use Process.getStartElapsedRealtime() for accurate measurement
97
+ val startElapsed = android.os.Process.getStartElapsedRealtime()
98
+ val nowElapsed = android.os.SystemClock.elapsedRealtime()
99
+ System.currentTimeMillis() - (nowElapsed - startElapsed)
100
+ } else {
101
+ // Fallback for older devices: use current time (less accurate)
102
+ System.currentTimeMillis()
103
+ }
104
+ } catch (e: Exception) {
105
+ System.currentTimeMillis()
106
+ }
107
+ }
108
+ }
109
+
110
+ // Core components
111
+ private var captureEngine: CaptureEngine? = null
112
+ private var uploadManager: UploadManager? = null
113
+ private var touchInterceptor: TouchInterceptor? = null
114
+ private var deviceAuthManager: DeviceAuthManager? = null
115
+ private var networkMonitor: NetworkMonitor? = null
116
+ private var keyboardTracker: KeyboardTracker? = null
117
+ private var textInputTracker: TextInputTracker? = null
118
+
119
+ // Session state
120
+ private var currentSessionId: String? = null
121
+ private var userId: String? = null
122
+ @Volatile private var isRecording: Boolean = false
123
+ @Volatile private var remoteRejourneyEnabled: Boolean = true
124
+ @Volatile private var remoteRecordingEnabled: Boolean = true
125
+ @Volatile private var recordingEnabledByConfig: Boolean = true
126
+ @Volatile private var sessionSampled: Boolean = true
127
+ @Volatile private var hasSampleDecision: Boolean = false
128
+ @Volatile private var hasProjectConfig: Boolean = false
129
+ private var projectSampleRate: Int = 100
130
+ private var sessionStartTime: Long = 0
131
+ private var totalBackgroundTimeMs: Long = 0
132
+ private var backgroundEntryTime: Long = 0
133
+ private var wasInBackground: Boolean = false
134
+ private var maxRecordingMinutes: Int = 10
135
+ @Volatile private var sessionEndSent: Boolean = false
136
+
137
+ // Keyboard state
138
+ private var keyPressCount: Int = 0
139
+ private var isKeyboardVisible: Boolean = false
140
+ private var lastKeyboardHeight: Int = 0
141
+
142
+ // Session state saved on background - used to restore on foreground if within timeout
143
+ private var savedApiUrl: String = ""
144
+ private var savedPublicKey: String = ""
145
+ private var savedDeviceHash: String = ""
146
+
147
+ // Events buffer
148
+ private val sessionEvents = CopyOnWriteArrayList<Map<String, Any?>>()
149
+
150
+ // Throttle immediate upload kicks (ms)
151
+ @Volatile private var lastImmediateUploadKickMs: Long = 0
152
+
153
+ // Write-first event buffer for crash-safe persistence
154
+ private var eventBuffer: EventBuffer? = null
155
+
156
+ // Coroutine scope for async operations
157
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
158
+
159
+ // Dedicated scope for background flush - survives independently of main scope
160
+ // This prevents cancellation when app goes to background
161
+ private val backgroundScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
162
+
163
+ // Timer jobs
164
+ private var batchUploadJob: Job? = null
165
+ private var durationLimitJob: Job? = null
166
+
167
+ // Main thread handler for posting delayed tasks
168
+ private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
169
+
170
+ // Debounced background detection (prevents transient pauses from ending sessions)
171
+ private var scheduledBackgroundRunnable: Runnable? = null
172
+ private var backgroundScheduled: Boolean = false
173
+
174
+ // Safety flag
175
+ @Volatile private var isShuttingDown = false
176
+
177
+ // Auth resilience - retry mechanism
178
+ private var authRetryCount = 0
179
+ private var authPermanentlyFailed = false
180
+ private var authRetryJob: Job? = null
181
+
182
+ init {
183
+ // DO NOT initialize anything here that could throw exceptions
184
+ // React Native needs the module constructor to complete cleanly
185
+ // All initialization will happen lazily on first method call
186
+ Logger.debug("RejourneyModuleImpl constructor completed")
187
+ }
188
+
189
+ // Lazy initialization flag
190
+ @Volatile
191
+ private var isInitialized = false
192
+ private val initLock = Any()
193
+
194
+ /**
195
+ * Lazy initialization - called on first method invocation.
196
+ * This ensures the module constructor completes successfully for React Native.
197
+ */
198
+ private fun ensureInitialized() {
199
+ if (isInitialized) return
200
+
201
+ synchronized(initLock) {
202
+ if (isInitialized) return
203
+
204
+ try {
205
+ logReactNativeArchitecture()
206
+ setupComponents()
207
+ registerActivityLifecycleCallbacks()
208
+ registerProcessLifecycleObserver()
209
+
210
+ // Start crash handler, ANR handler, and network monitor with error handling
211
+ try {
212
+ CrashHandler.getInstance(reactContext).startMonitoring()
213
+ } catch (e: Exception) {
214
+ Logger.error("Failed to start crash handler (non-critical)", e)
215
+ }
216
+
217
+ try {
218
+ ANRHandler.getInstance(reactContext).apply {
219
+ listener = this@RejourneyModuleImpl
220
+ startMonitoring()
221
+ }
222
+ } catch (e: Exception) {
223
+ Logger.error("Failed to start ANR handler (non-critical)", e)
224
+ }
225
+
226
+ try {
227
+ NetworkMonitor.getInstance(reactContext).startMonitoring()
228
+ } catch (e: Exception) {
229
+ Logger.error("Failed to start network monitor (non-critical)", e)
230
+ }
231
+
232
+ // Schedule recovery of any pending uploads from previous sessions
233
+ // This handles cases where the app was killed before uploads completed
234
+ try {
235
+ UploadWorker.scheduleRecoveryUpload(reactContext)
236
+ } catch (e: Exception) {
237
+ Logger.error("Failed to schedule recovery upload (non-critical)", e)
238
+ }
239
+
240
+ // Check if app was killed in previous session (Android 11+)
241
+ try {
242
+ checkPreviousAppKill()
243
+ } catch (e: Exception) {
244
+ Logger.error("Failed to check previous app kill (non-critical)", e)
245
+ }
246
+
247
+ // Check for unclosed sessions from previous launch
248
+ try {
249
+ checkForUnclosedSessions()
250
+ } catch (e: Exception) {
251
+ Logger.error("Failed to check for unclosed sessions (non-critical)", e)
252
+ }
253
+
254
+ // Log OEM information for debugging
255
+ val oem = OEMDetector.getOEM()
256
+ Logger.debug("Device OEM: $oem")
257
+ Logger.debug("OEM Recommendations: ${OEMDetector.getRecommendations()}")
258
+ Logger.debug("onTaskRemoved() reliable: ${OEMDetector.isTaskRemovedReliable()}")
259
+
260
+ // Set up SessionLifecycleService listener to detect app termination
261
+ try {
262
+ SessionLifecycleService.taskRemovedListener = object : TaskRemovedListener {
263
+ override fun onTaskRemoved() {
264
+ Logger.debug("[Rejourney] App terminated via swipe-away - SYNCHRONOUS session end (OEM: $oem)")
265
+
266
+ // CRITICAL: Use runBlocking to ensure session end completes BEFORE process death
267
+ // The previous async implementation using scope.launch would not complete in time
268
+ // because the process would be killed before the coroutine executed.
269
+ if (isRecording && !sessionEndSent) {
270
+ try {
271
+ // Use runBlocking with a timeout to ensure we don't block indefinitely
272
+ // but still give enough time for critical operations (HTTP calls)
273
+ runBlocking {
274
+ withTimeout(5000L) { // 5 second timeout
275
+ Logger.debug("[Rejourney] Starting synchronous session end...")
276
+ endSessionSynchronous()
277
+ Logger.debug("[Rejourney] Synchronous session end completed")
278
+ }
279
+ }
280
+ } catch (e: TimeoutCancellationException) {
281
+ Logger.warning("[Rejourney] Session end timed out after 5s - WorkManager will recover")
282
+ } catch (e: Exception) {
283
+ Logger.error("[Rejourney] Failed to end session on task removed", e)
284
+ }
285
+ } else {
286
+ Logger.debug("[Rejourney] Session already ended or not recording - skipping")
287
+ }
288
+ }
289
+ }
290
+ } catch (e: Exception) {
291
+ Logger.error("Failed to set up task removed listener (non-critical)", e)
292
+ }
293
+
294
+ // Use lifecycle log - only shown in debug builds
295
+ Logger.logInitSuccess(Constants.SDK_VERSION)
296
+
297
+ isInitialized = true
298
+ } catch (e: Exception) {
299
+ Logger.logInitFailure("${e.javaClass.simpleName}: ${e.message}")
300
+ // Mark as initialized anyway to prevent retry loops
301
+ isInitialized = true
302
+ }
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Adds an event with immediate disk persistence for crash safety.
308
+ * This is the industry-standard approach for volume control.
309
+ */
310
+ private fun addEventWithPersistence(event: Map<String, Any?>) {
311
+ val eventType = event["type"]?.toString() ?: "unknown"
312
+ val sessionId = currentSessionId ?: "no-session"
313
+
314
+ Logger.debug("[Rejourney] addEventWithPersistence: type=$eventType, sessionId=$sessionId, inMemoryCount=${sessionEvents.size + 1}")
315
+
316
+ // CRITICAL: Write to disk immediately for crash safety
317
+ // This ensures events are never lost even if app is force-killed
318
+ val bufferSuccess = eventBuffer?.appendEvent(event) ?: false
319
+ if (!bufferSuccess) {
320
+ Logger.warning("[Rejourney] addEventWithPersistence: Failed to append event to buffer: type=$eventType")
321
+ } else {
322
+ Logger.debug("[Rejourney] addEventWithPersistence: Event appended to buffer: type=$eventType")
323
+ }
324
+
325
+ // Also add to in-memory buffer for batched upload
326
+ sessionEvents.add(event)
327
+ Logger.debug("[Rejourney] addEventWithPersistence: Event added to in-memory list: type=$eventType, totalInMemory=${sessionEvents.size}")
328
+ }
329
+
330
+ /**
331
+ * Register with ProcessLifecycleOwner for reliable app foreground/background detection.
332
+ * This is more reliable than Activity lifecycle callbacks.
333
+ */
334
+ private fun registerProcessLifecycleObserver() {
335
+ // Must run on main thread
336
+ Handler(Looper.getMainLooper()).post {
337
+ try {
338
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
339
+ Logger.debug("ProcessLifecycleOwner observer registered")
340
+ } catch (e: Exception) {
341
+ Logger.error("Failed to register ProcessLifecycleOwner observer (non-critical)", e)
342
+ // This is non-critical - we can still use Activity lifecycle callbacks as fallback
343
+ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Log which React Native architecture is being used.
349
+ */
350
+ private fun logReactNativeArchitecture() {
351
+ val archType = if (isNewArchitecture) "New Architecture (TurboModules)" else "Old Architecture (Bridge)"
352
+ Logger.logArchitectureInfo(isNewArchitecture, archType)
353
+ }
354
+
355
+ private fun setupComponents() {
356
+ try {
357
+ // Initialize capture engine with video segment mode
358
+ captureEngine = CaptureEngine(reactContext).apply {
359
+ captureScale = Constants.DEFAULT_CAPTURE_SCALE
360
+ minFrameInterval = Constants.DEFAULT_MIN_FRAME_INTERVAL
361
+ maxFramesPerMinute = Constants.DEFAULT_MAX_FRAMES_PER_MINUTE
362
+ targetBitrate = Constants.DEFAULT_VIDEO_BITRATE
363
+ targetFps = Constants.DEFAULT_VIDEO_FPS
364
+ framesPerSegment = Constants.DEFAULT_FRAMES_PER_SEGMENT
365
+ delegate = this@RejourneyModuleImpl
366
+ }
367
+ Logger.debug("CaptureEngine initialized")
368
+ } catch (e: Exception) {
369
+ Logger.error("Failed to initialize CaptureEngine", e)
370
+ captureEngine = null
371
+ }
372
+
373
+ try {
374
+ // Initialize upload manager
375
+ uploadManager = UploadManager(reactContext, "https://api.rejourney.co")
376
+ Logger.debug("UploadManager initialized")
377
+ } catch (e: Exception) {
378
+ Logger.error("Failed to initialize UploadManager", e)
379
+ uploadManager = null
380
+ }
381
+
382
+ try {
383
+ // Initialize touch interceptor
384
+ touchInterceptor = TouchInterceptor.getInstance(reactContext).apply {
385
+ delegate = this@RejourneyModuleImpl
386
+ }
387
+ Logger.debug("TouchInterceptor initialized")
388
+ } catch (e: Exception) {
389
+ Logger.error("Failed to initialize TouchInterceptor", e)
390
+ touchInterceptor = null
391
+ }
392
+
393
+ try {
394
+ // Initialize device auth manager
395
+ deviceAuthManager = DeviceAuthManager.getInstance(reactContext).apply {
396
+ authFailureListener = this@RejourneyModuleImpl
397
+ }
398
+ Logger.debug("DeviceAuthManager initialized")
399
+ } catch (e: Exception) {
400
+ Logger.error("Failed to initialize DeviceAuthManager", e)
401
+ deviceAuthManager = null
402
+ }
403
+
404
+ try {
405
+ // Initialize network monitor
406
+ networkMonitor = NetworkMonitor.getInstance(reactContext).apply {
407
+ listener = this@RejourneyModuleImpl
408
+ }
409
+ Logger.debug("NetworkMonitor initialized")
410
+ } catch (e: Exception) {
411
+ Logger.error("Failed to initialize NetworkMonitor", e)
412
+ networkMonitor = null
413
+ }
414
+
415
+ try {
416
+ // Initialize keyboard tracker (for keyboard show/hide events)
417
+ keyboardTracker = KeyboardTracker.getInstance(reactContext).apply {
418
+ listener = this@RejourneyModuleImpl
419
+ }
420
+ Logger.debug("KeyboardTracker initialized")
421
+ } catch (e: Exception) {
422
+ Logger.error("Failed to initialize KeyboardTracker", e)
423
+ keyboardTracker = null
424
+ }
425
+
426
+ try {
427
+ // Initialize text input tracker (for key press counting)
428
+ textInputTracker = TextInputTracker.getInstance(reactContext).apply {
429
+ listener = this@RejourneyModuleImpl
430
+ }
431
+ Logger.debug("TextInputTracker initialized")
432
+ } catch (e: Exception) {
433
+ Logger.error("Failed to initialize TextInputTracker", e)
434
+ textInputTracker = null
435
+ }
436
+ }
437
+
438
+ private fun registerActivityLifecycleCallbacks() {
439
+ try {
440
+ val application = reactContext.applicationContext as? Application
441
+ if (application != null) {
442
+ application.registerActivityLifecycleCallbacks(this)
443
+ Logger.debug("Activity lifecycle callbacks registered")
444
+ } else {
445
+ Logger.error("Failed to register activity lifecycle callbacks - application context is not Application type")
446
+ }
447
+ } catch (e: Exception) {
448
+ Logger.error("Failed to register activity lifecycle callbacks", e)
449
+ }
450
+ }
451
+
452
+ fun invalidate() {
453
+ isShuttingDown = true
454
+ scope.cancel()
455
+ stopBatchUploadTimer()
456
+ stopDurationLimitTimer()
457
+ touchInterceptor?.disableGlobalTracking()
458
+ keyboardTracker?.stopTracking()
459
+ textInputTracker?.stopTracking()
460
+ networkMonitor?.stopMonitoring()
461
+
462
+ val application = reactContext.applicationContext as? Application
463
+ application?.unregisterActivityLifecycleCallbacks(this)
464
+
465
+ // Unregister from ProcessLifecycleOwner
466
+ Handler(Looper.getMainLooper()).post {
467
+ try {
468
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
469
+ } catch (e: Exception) {
470
+ // Ignore
471
+ }
472
+ }
473
+ }
474
+
475
+ // ==================== React Native Methods ====================
476
+
477
+ fun startSession(userId: String, apiUrl: String, publicKey: String, promise: Promise) {
478
+ ensureInitialized() // Lazy init on first call
479
+
480
+ if (isShuttingDown) {
481
+ promise.resolve(createResultMap(false, "", "Module is shutting down"))
482
+ return
483
+ }
484
+
485
+ // Optimistically allow start; remote config will shut down if disabled.
486
+ remoteRejourneyEnabled = true
487
+
488
+ scope.launch {
489
+ try {
490
+ if (isRecording) {
491
+ promise.resolve(createResultMap(true, currentSessionId ?: ""))
492
+ return@launch
493
+ }
494
+
495
+ val safeUserId = userId.ifEmpty { "anonymous" }
496
+ val safeApiUrl = apiUrl.ifEmpty { "https://api.rejourney.co" }
497
+ val safePublicKey = publicKey.ifEmpty { "" }
498
+
499
+ // Generate device hash
500
+ val androidId = Settings.Secure.getString(
501
+ reactContext.contentResolver,
502
+ Settings.Secure.ANDROID_ID
503
+ ) ?: "unknown"
504
+ val deviceHash = generateSHA256Hash(androidId)
505
+
506
+ // Setup session
507
+ this@RejourneyModuleImpl.userId = safeUserId
508
+ currentSessionId = WindowUtils.generateSessionId()
509
+ sessionStartTime = System.currentTimeMillis()
510
+ totalBackgroundTimeMs = 0
511
+ sessionEndSent = false
512
+ sessionEvents.clear()
513
+
514
+ // Reset remote recording flag for this session until config says otherwise
515
+ remoteRecordingEnabled = true
516
+ recordingEnabledByConfig = true
517
+ projectSampleRate = 100
518
+ hasProjectConfig = false
519
+ resetSamplingDecision()
520
+
521
+ // Save session ID for crash handler
522
+ reactContext.getSharedPreferences("rejourney", 0)
523
+ .edit()
524
+ .putString("rj_current_session_id", currentSessionId)
525
+ .apply()
526
+
527
+ // Configure upload manager
528
+ uploadManager?.apply {
529
+ this.apiUrl = safeApiUrl
530
+ this.publicKey = safePublicKey
531
+ this.deviceHash = deviceHash
532
+ // NUCLEAR FIX: Use setActiveSessionId() to protect from recovery corruption
533
+ setActiveSessionId(currentSessionId!!)
534
+ this.userId = safeUserId
535
+ this.sessionStartTime = this@RejourneyModuleImpl.sessionStartTime
536
+ resetForNewSession()
537
+ }
538
+
539
+ // Mark session active for crash recovery (disk-backed)
540
+ currentSessionId?.let { sid ->
541
+ uploadManager?.markSessionActive(sid, sessionStartTime)
542
+
543
+ // Also save to SharedPreferences for unclosed session detection
544
+ reactContext.getSharedPreferences("rejourney", 0)
545
+ .edit()
546
+ .putString("rj_current_session_id", sid)
547
+ .putLong("rj_session_start_time", sessionStartTime)
548
+ .apply()
549
+ }
550
+
551
+ // Initialize write-first event buffer for crash-safe persistence
552
+ val pendingDir = java.io.File(reactContext.cacheDir, "rj_pending")
553
+ currentSessionId?.let { sid ->
554
+ eventBuffer = EventBuffer(reactContext, sid, pendingDir)
555
+ }
556
+
557
+ // Save config for auto-resume on quick background return
558
+ savedApiUrl = safeApiUrl
559
+ savedPublicKey = safePublicKey
560
+ savedDeviceHash = deviceHash
561
+
562
+ // Start capture engine only if recording is enabled remotely
563
+ if (remoteRecordingEnabled) {
564
+ captureEngine?.startSession(currentSessionId!!)
565
+ }
566
+
567
+ // Enable touch tracking
568
+ touchInterceptor?.enableGlobalTracking()
569
+
570
+ // Start keyboard and text input tracking
571
+ keyboardTracker?.startTracking()
572
+ textInputTracker?.startTracking()
573
+
574
+ // Mark as recording
575
+ isRecording = true
576
+
577
+ // Start SessionLifecycleService to detect app termination
578
+ try {
579
+ val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
580
+ reactContext.startService(serviceIntent)
581
+ Logger.debug("SessionLifecycleService started")
582
+ } catch (e: Exception) {
583
+ Logger.warning("Failed to start SessionLifecycleService: ${e.message}")
584
+ }
585
+
586
+ // Start batch uploads
587
+ startBatchUploadTimer()
588
+ startDurationLimitTimer()
589
+
590
+ // Emit app_startup event with startup duration
591
+ // This is the time from process start to session start
592
+ val nowMs = System.currentTimeMillis()
593
+ val startupDurationMs = nowMs - processStartTimeMs
594
+ if (startupDurationMs > 0 && startupDurationMs < 60000) { // Sanity check: < 60s
595
+ val startupEvent = mapOf(
596
+ "type" to "app_startup",
597
+ "timestamp" to nowMs,
598
+ "durationMs" to startupDurationMs,
599
+ "platform" to "android"
600
+ )
601
+ addEventWithPersistence(startupEvent)
602
+ Logger.debug("Recorded app startup time: ${startupDurationMs}ms")
603
+ }
604
+
605
+ // Fetch project config
606
+ fetchProjectConfig(safePublicKey, safeApiUrl)
607
+
608
+ // Register device
609
+ registerDevice(safePublicKey, safeApiUrl)
610
+
611
+ // Use lifecycle log for session start - only shown in debug builds
612
+ Logger.logSessionStart(currentSessionId ?: "")
613
+
614
+ promise.resolve(createResultMap(true, currentSessionId ?: ""))
615
+ } catch (e: Exception) {
616
+ Logger.error("Failed to start session", e)
617
+ isRecording = false
618
+ promise.resolve(createResultMap(false, "", e.message))
619
+ }
620
+ }
621
+ }
622
+
623
+ fun stopSession(promise: Promise) {
624
+ if (isShuttingDown) {
625
+ promise.resolve(createStopResultMap(false, "", false, null, "Module is shutting down"))
626
+ return
627
+ }
628
+
629
+ scope.launch {
630
+ try {
631
+ if (!isRecording) {
632
+ promise.resolve(createStopResultMap(false, "", false, "Not recording", null))
633
+ return@launch
634
+ }
635
+
636
+ val sessionId = currentSessionId ?: ""
637
+
638
+ // Stop timers
639
+ stopBatchUploadTimer()
640
+ stopDurationLimitTimer()
641
+
642
+ // Force final capture
643
+ if (remoteRecordingEnabled) {
644
+ captureEngine?.forceCaptureWithReason("session_end")
645
+ }
646
+
647
+ // Stop capture engine (triggers final segment upload via delegate)
648
+ captureEngine?.stopSession()
649
+
650
+ // Disable touch tracking
651
+ touchInterceptor?.disableGlobalTracking()
652
+
653
+ // Build metrics for promotion evaluation
654
+ var crashCount = 0
655
+ var anrCount = 0
656
+ var errorCount = 0
657
+ for (event in sessionEvents) {
658
+ when (event["type"]) {
659
+ "crash" -> crashCount++
660
+ "anr" -> anrCount++
661
+ "error" -> errorCount++
662
+ }
663
+ }
664
+ val durationSeconds = ((System.currentTimeMillis() - sessionStartTime) / 1000).toInt()
665
+
666
+ val metrics = mapOf(
667
+ "crashCount" to crashCount,
668
+ "anrCount" to anrCount,
669
+ "errorCount" to errorCount,
670
+ "durationSeconds" to durationSeconds
671
+ )
672
+
673
+ // Evaluate replay promotion
674
+ val promotionResult = uploadManager?.evaluateReplayPromotion(metrics)
675
+ val isPromoted = promotionResult?.first ?: false
676
+ val reason = promotionResult?.second ?: "unknown"
677
+
678
+ if (isPromoted) {
679
+ Logger.debug("Session promoted (reason: $reason)")
680
+ } else {
681
+ Logger.debug("Session not promoted (reason: $reason)")
682
+ }
683
+
684
+ // Upload remaining events (video segments uploaded via delegate callbacks)
685
+ val uploadSuccess = uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
686
+
687
+ // Send session end signal if not already sent
688
+ var endSessionSuccess = sessionEndSent // Already sent counts as success
689
+ if (!sessionEndSent) {
690
+ sessionEndSent = true
691
+ endSessionSuccess = uploadManager?.endSession() ?: false
692
+ if (!endSessionSuccess) {
693
+ Logger.warning("Session end signal may have failed")
694
+ }
695
+ }
696
+
697
+ // Clear crash recovery markers only if the session is actually closed
698
+ if (endSessionSuccess) {
699
+ currentSessionId?.let { sid ->
700
+ uploadManager?.clearSessionRecovery(sid)
701
+
702
+ // Mark session as closed in SharedPreferences
703
+ reactContext.getSharedPreferences("rejourney", 0)
704
+ .edit()
705
+ .putLong("rj_session_end_time_$sid", System.currentTimeMillis())
706
+ .remove("rj_current_session_id")
707
+ .remove("rj_session_start_time")
708
+ .apply()
709
+ }
710
+ }
711
+
712
+ // Clear state
713
+ isRecording = false
714
+ currentSessionId = null
715
+ this@RejourneyModuleImpl.userId = null
716
+ sessionEvents.clear()
717
+
718
+ // Stop SessionLifecycleService
719
+ try {
720
+ val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
721
+ reactContext.stopService(serviceIntent)
722
+ Logger.debug("SessionLifecycleService stopped")
723
+ } catch (e: Exception) {
724
+ Logger.warning("Failed to stop SessionLifecycleService: ${e.message}")
725
+ }
726
+
727
+ // Use lifecycle log for session end - only shown in debug builds
728
+ Logger.logSessionEnd(sessionId)
729
+
730
+ promise.resolve(createStopResultMap(true, sessionId, uploadSuccess && endSessionSuccess, null, null))
731
+ } catch (e: Exception) {
732
+ Logger.error("Failed to stop session", e)
733
+ isRecording = false
734
+ promise.resolve(createStopResultMap(false, currentSessionId ?: "", false, null, e.message))
735
+ }
736
+ }
737
+ }
738
+
739
+ fun logEvent(eventType: String, details: ReadableMap, promise: Promise) {
740
+ if (!isRecording || isShuttingDown) {
741
+ promise.resolve(createSuccessMap(false))
742
+ return
743
+ }
744
+
745
+ try {
746
+ val event = mapOf(
747
+ "type" to eventType,
748
+ "timestamp" to System.currentTimeMillis(),
749
+ "details" to details.toHashMap()
750
+ )
751
+ addEventWithPersistence(event)
752
+ promise.resolve(createSuccessMap(true))
753
+ } catch (e: Exception) {
754
+ Logger.error("Failed to log event", e)
755
+ promise.resolve(createSuccessMap(false))
756
+ }
757
+ }
758
+
759
+ fun screenChanged(screenName: String, promise: Promise) {
760
+ if (!isRecording || isShuttingDown) {
761
+ promise.resolve(createSuccessMap(false))
762
+ return
763
+ }
764
+
765
+ try {
766
+ Logger.debug("Screen changed to: $screenName")
767
+
768
+ val event = mapOf(
769
+ "type" to EventType.NAVIGATION,
770
+ "timestamp" to System.currentTimeMillis(),
771
+ "screenName" to screenName
772
+ )
773
+ addEventWithPersistence(event)
774
+
775
+ // Notify capture engine with delay for render
776
+ scope.launch {
777
+ delay(100)
778
+ captureEngine?.notifyNavigationToScreen(screenName)
779
+ captureEngine?.notifyReactNativeCommit()
780
+ }
781
+
782
+ promise.resolve(createSuccessMap(true))
783
+ } catch (e: Exception) {
784
+ Logger.error("Failed to handle screen change", e)
785
+ promise.resolve(createSuccessMap(false))
786
+ }
787
+ }
788
+
789
+ fun onScroll(offsetY: Double, promise: Promise) {
790
+ if (!isRecording || isShuttingDown) {
791
+ promise.resolve(createSuccessMap(false))
792
+ return
793
+ }
794
+
795
+ try {
796
+ captureEngine?.notifyScrollOffset(offsetY.toFloat())
797
+ promise.resolve(createSuccessMap(true))
798
+ } catch (e: Exception) {
799
+ promise.resolve(createSuccessMap(false))
800
+ }
801
+ }
802
+
803
+ fun markVisualChange(reason: String, importance: String, promise: Promise) {
804
+ if (!isRecording || isShuttingDown) {
805
+ promise.resolve(false)
806
+ return
807
+ }
808
+
809
+ try {
810
+ val importanceLevel = when (importance.lowercase()) {
811
+ "low" -> com.rejourney.core.CaptureImportance.LOW
812
+ "medium" -> com.rejourney.core.CaptureImportance.MEDIUM
813
+ "high" -> com.rejourney.core.CaptureImportance.HIGH
814
+ "critical" -> com.rejourney.core.CaptureImportance.CRITICAL
815
+ else -> com.rejourney.core.CaptureImportance.MEDIUM
816
+ }
817
+ captureEngine?.notifyVisualChange(reason, importanceLevel)
818
+ promise.resolve(true)
819
+ } catch (e: Exception) {
820
+ promise.resolve(false)
821
+ }
822
+ }
823
+
824
+ fun onExternalURLOpened(urlScheme: String, promise: Promise) {
825
+ if (!isRecording || isShuttingDown) {
826
+ promise.resolve(createSuccessMap(false))
827
+ return
828
+ }
829
+
830
+ try {
831
+ val event = mapOf(
832
+ "type" to EventType.EXTERNAL_URL_OPENED,
833
+ "timestamp" to System.currentTimeMillis(),
834
+ "urlScheme" to urlScheme
835
+ )
836
+ addEventWithPersistence(event)
837
+ promise.resolve(createSuccessMap(true))
838
+ } catch (e: Exception) {
839
+ promise.resolve(createSuccessMap(false))
840
+ }
841
+ }
842
+
843
+ fun onOAuthStarted(provider: String, promise: Promise) {
844
+ if (!isRecording || isShuttingDown) {
845
+ promise.resolve(createSuccessMap(false))
846
+ return
847
+ }
848
+
849
+ try {
850
+ val event = mapOf(
851
+ "type" to EventType.OAUTH_STARTED,
852
+ "timestamp" to System.currentTimeMillis(),
853
+ "provider" to provider
854
+ )
855
+ addEventWithPersistence(event)
856
+ promise.resolve(createSuccessMap(true))
857
+ } catch (e: Exception) {
858
+ promise.resolve(createSuccessMap(false))
859
+ }
860
+ }
861
+
862
+ fun onOAuthCompleted(provider: String, success: Boolean, promise: Promise) {
863
+ if (!isRecording || isShuttingDown) {
864
+ promise.resolve(createSuccessMap(false))
865
+ return
866
+ }
867
+
868
+ try {
869
+ val event = mapOf(
870
+ "type" to EventType.OAUTH_COMPLETED,
871
+ "timestamp" to System.currentTimeMillis(),
872
+ "provider" to provider,
873
+ "success" to success
874
+ )
875
+ addEventWithPersistence(event)
876
+ promise.resolve(createSuccessMap(true))
877
+ } catch (e: Exception) {
878
+ promise.resolve(createSuccessMap(false))
879
+ }
880
+ }
881
+
882
+ fun getSDKMetrics(promise: Promise) {
883
+ try {
884
+ val metrics = Telemetry.getInstance().currentMetrics()
885
+ val map = Arguments.createMap().apply {
886
+ putInt("uploadSuccessCount", metrics.uploadSuccessCount)
887
+ putInt("uploadFailureCount", metrics.uploadFailureCount)
888
+ putInt("retryAttemptCount", metrics.retryAttemptCount)
889
+ putInt("circuitBreakerOpenCount", metrics.circuitBreakerOpenCount)
890
+ putInt("memoryEvictionCount", metrics.memoryEvictionCount)
891
+ putInt("offlinePersistCount", metrics.offlinePersistCount)
892
+ putInt("sessionStartCount", metrics.sessionStartCount)
893
+ putInt("crashCount", metrics.crashCount)
894
+ putInt("anrCount", metrics.anrCount)
895
+ putDouble("uploadSuccessRate", metrics.uploadSuccessRate.toDouble())
896
+ putDouble("avgUploadDurationMs", metrics.avgUploadDurationMs.toDouble())
897
+ putInt("currentQueueDepth", metrics.currentQueueDepth)
898
+ metrics.lastUploadTime?.let { value -> putDouble("lastUploadTime", value.toDouble()) }
899
+ metrics.lastRetryTime?.let { value -> putDouble("lastRetryTime", value.toDouble()) }
900
+ putDouble("totalBytesUploaded", metrics.totalBytesUploaded.toDouble())
901
+ putDouble("totalBytesEvicted", metrics.totalBytesEvicted.toDouble())
902
+ }
903
+ promise.resolve(map)
904
+ } catch (e: Exception) {
905
+ Logger.error("Failed to get SDK metrics", e)
906
+ promise.resolve(Arguments.createMap())
907
+ }
908
+ }
909
+
910
+ fun setDebugMode(enabled: Boolean, promise: Promise) {
911
+ try {
912
+ Logger.setDebugMode(enabled)
913
+ promise.resolve(createSuccessMap(true))
914
+ } catch (e: Exception) {
915
+ promise.resolve(createSuccessMap(false))
916
+ }
917
+ }
918
+
919
+ fun debugCrash() {
920
+ Logger.debug("Triggering debug crash...")
921
+ scope.launch(Dispatchers.Main) {
922
+ throw RuntimeException("This is a test crash triggered from React Native")
923
+ }
924
+ }
925
+
926
+ fun debugTriggerANR(durationMs: Double) {
927
+ Logger.debug("Triggering debug ANR for ${durationMs.toLong()}ms...")
928
+ // Post to main looper to block the main thread
929
+ Handler(Looper.getMainLooper()).post {
930
+ try {
931
+ Thread.sleep(durationMs.toLong())
932
+ } catch (e: InterruptedException) {
933
+ e.printStackTrace()
934
+ }
935
+ }
936
+ }
937
+
938
+ fun getSessionId(promise: Promise) {
939
+ promise.resolve(currentSessionId)
940
+ }
941
+
942
+ // ==================== Privacy / View Masking ====================
943
+
944
+ fun maskViewByNativeID(nativeID: String, promise: Promise) {
945
+ if (nativeID.isEmpty()) {
946
+ promise.resolve(createSuccessMap(false))
947
+ return
948
+ }
949
+
950
+ try {
951
+ // Add nativeID to the privacy mask set - will be checked during capture
952
+ // This is robust because we don't need to find the view immediately
953
+ com.rejourney.privacy.PrivacyMask.addMaskedNativeID(nativeID)
954
+ Logger.debug("Masked nativeID: $nativeID")
955
+ promise.resolve(createSuccessMap(true))
956
+ } catch (e: Exception) {
957
+ Logger.warning("maskViewByNativeID failed: ${e.message}")
958
+ promise.resolve(createSuccessMap(false))
959
+ }
960
+ }
961
+
962
+ fun unmaskViewByNativeID(nativeID: String, promise: Promise) {
963
+ if (nativeID.isEmpty()) {
964
+ promise.resolve(createSuccessMap(false))
965
+ return
966
+ }
967
+
968
+ try {
969
+ // Remove nativeID from the privacy mask set
970
+ com.rejourney.privacy.PrivacyMask.removeMaskedNativeID(nativeID)
971
+ Logger.debug("Unmasked nativeID: $nativeID")
972
+ promise.resolve(createSuccessMap(true))
973
+ } catch (e: Exception) {
974
+ Logger.warning("unmaskViewByNativeID failed: ${e.message}")
975
+ promise.resolve(createSuccessMap(false))
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Recursively find a view with a given nativeID.
981
+ * In React Native, nativeID is typically stored in the view's tag or as a resource ID.
982
+ */
983
+ private fun findViewByNativeID(view: android.view.View, nativeID: String): android.view.View? {
984
+ // Check if view has matching tag (common RN pattern)
985
+ val viewTag = view.getTag(com.facebook.react.R.id.view_tag_native_id)
986
+ if (viewTag is String && viewTag == nativeID) {
987
+ return view
988
+ }
989
+
990
+ // Recurse into ViewGroup children
991
+ if (view is android.view.ViewGroup) {
992
+ for (i in 0 until view.childCount) {
993
+ val child = view.getChildAt(i)
994
+ val found = findViewByNativeID(child, nativeID)
995
+ if (found != null) return found
996
+ }
997
+ }
998
+
999
+ return null
1000
+ }
1001
+
1002
+ // ==================== User Identity ====================
1003
+
1004
+ fun setUserIdentity(userId: String, promise: Promise) {
1005
+ try {
1006
+ val safeUserId = userId.ifEmpty { "anonymous" }
1007
+
1008
+ // Update userId
1009
+ this.userId = safeUserId
1010
+
1011
+ // Update upload manager
1012
+ uploadManager?.userId = safeUserId
1013
+
1014
+ Logger.debug("User identity updated: $safeUserId")
1015
+
1016
+ // Log event for tracking
1017
+ if (isRecording) {
1018
+ val event = mapOf(
1019
+ "type" to "user_identity_changed",
1020
+ "timestamp" to System.currentTimeMillis(),
1021
+ "userId" to safeUserId
1022
+ )
1023
+ addEventWithPersistence(event)
1024
+ }
1025
+
1026
+ promise.resolve(createSuccessMap(true))
1027
+ } catch (e: Exception) {
1028
+ Logger.warning("setUserIdentity failed: ${e.message}")
1029
+ promise.resolve(createSuccessMap(false))
1030
+ }
1031
+ }
1032
+
1033
+ // ==================== Helper Methods ====================
1034
+
1035
+ private fun createResultMap(success: Boolean, sessionId: String, error: String? = null): WritableMap {
1036
+ return Arguments.createMap().apply {
1037
+ putBoolean("success", success)
1038
+ putString("sessionId", sessionId)
1039
+ error?.let { putString("error", it) }
1040
+ }
1041
+ }
1042
+
1043
+ private fun createStopResultMap(
1044
+ success: Boolean,
1045
+ sessionId: String,
1046
+ uploadSuccess: Boolean,
1047
+ warning: String?,
1048
+ error: String?
1049
+ ): WritableMap {
1050
+ return Arguments.createMap().apply {
1051
+ putBoolean("success", success)
1052
+ putString("sessionId", sessionId)
1053
+ putBoolean("uploadSuccess", uploadSuccess)
1054
+ warning?.let { putString("warning", it) }
1055
+ error?.let { putString("error", it) }
1056
+ }
1057
+ }
1058
+
1059
+ private fun createSuccessMap(success: Boolean): WritableMap {
1060
+ return Arguments.createMap().apply {
1061
+ putBoolean("success", success)
1062
+ }
1063
+ }
1064
+
1065
+ private fun generateSHA256Hash(input: String): String {
1066
+ val digest = MessageDigest.getInstance("SHA-256")
1067
+ val hash = digest.digest(input.toByteArray())
1068
+ return hash.joinToString("") { "%02x".format(it) }
1069
+ }
1070
+
1071
+ private fun resetSamplingDecision() {
1072
+ sessionSampled = true
1073
+ hasSampleDecision = false
1074
+ }
1075
+
1076
+ private fun shouldSampleSession(sampleRate: Int): Boolean {
1077
+ val clampedRate = sampleRate.coerceIn(0, 100)
1078
+ if (clampedRate >= 100) return true
1079
+ if (clampedRate <= 0) return false
1080
+ return Random().nextInt(100) < clampedRate
1081
+ }
1082
+
1083
+ private fun updateRecordingEligibility(sampleRate: Int = projectSampleRate): Boolean {
1084
+ val clampedRate = sampleRate.coerceIn(0, 100)
1085
+ projectSampleRate = clampedRate
1086
+
1087
+ val decidedSample = if (!hasSampleDecision) {
1088
+ sessionSampled = shouldSampleSession(clampedRate)
1089
+ hasSampleDecision = true
1090
+ true
1091
+ } else {
1092
+ false
1093
+ }
1094
+
1095
+ val shouldRecord = recordingEnabledByConfig && sessionSampled
1096
+ remoteRecordingEnabled = shouldRecord
1097
+
1098
+ if (!shouldRecord && captureEngine?.isRecording == true) {
1099
+ captureEngine?.stopSession()
1100
+ }
1101
+
1102
+ if (decidedSample && recordingEnabledByConfig && !sessionSampled) {
1103
+ Logger.warning("Session skipped by sample rate (${clampedRate}%)")
1104
+ }
1105
+
1106
+ return shouldRecord
1107
+ }
1108
+
1109
+ private fun startBatchUploadTimer() {
1110
+ stopBatchUploadTimer()
1111
+ batchUploadJob = scope.launch {
1112
+ delay((Constants.INITIAL_UPLOAD_DELAY * 1000).toLong())
1113
+ while (isActive && isRecording) {
1114
+ performBatchUpload()
1115
+ delay((Constants.BATCH_UPLOAD_INTERVAL * 1000).toLong())
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ /**
1121
+ * Best-effort: trigger an upload/persist attempt soon after a keyframe is captured.
1122
+ * This materially improves crash sessions (more frames make it to disk quickly).
1123
+ */
1124
+ private fun scheduleImmediateUploadKick() {
1125
+ if (!isRecording || isShuttingDown) return
1126
+
1127
+ val now = System.currentTimeMillis()
1128
+ if (now - lastImmediateUploadKickMs < 1_000L) return
1129
+ lastImmediateUploadKickMs = now
1130
+
1131
+ scope.launch {
1132
+ try {
1133
+ performBatchUpload()
1134
+ } catch (_: Exception) {
1135
+ // Best-effort only
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ private fun stopBatchUploadTimer() {
1141
+ batchUploadJob?.cancel()
1142
+ batchUploadJob = null
1143
+ }
1144
+
1145
+ private fun startDurationLimitTimer() {
1146
+ stopDurationLimitTimer()
1147
+ val maxMs = maxRecordingMinutes * 60 * 1000L
1148
+ val elapsed = System.currentTimeMillis() - sessionStartTime
1149
+ val remaining = maxMs - elapsed
1150
+
1151
+ if (remaining > 0) {
1152
+ durationLimitJob = scope.launch {
1153
+ delay(remaining)
1154
+ if (isRecording) {
1155
+ Logger.warning("Recording duration limit reached, stopping session")
1156
+ stopSessionInternal()
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ private fun stopDurationLimitTimer() {
1163
+ durationLimitJob?.cancel()
1164
+ durationLimitJob = null
1165
+ }
1166
+
1167
+ private suspend fun performBatchUpload() {
1168
+ if (!isRecording || isShuttingDown) return
1169
+
1170
+ try {
1171
+ // Video segments are uploaded via CaptureEngineDelegate callbacks.
1172
+ // This timer now only handles event uploads.
1173
+
1174
+ val eventsToUpload = sessionEvents.toList()
1175
+
1176
+ if (eventsToUpload.isEmpty()) return
1177
+
1178
+ // Upload events only (video segments uploaded via delegate)
1179
+ val ok = uploadManager?.uploadBatch(eventsToUpload) ?: false
1180
+
1181
+ if (ok) {
1182
+ // Only clear events after data is safely uploaded
1183
+ sessionEvents.clear()
1184
+ }
1185
+ } catch (e: CancellationException) {
1186
+ // Normal cancellation (e.g., app going to background) - not an error
1187
+ // WorkManager will handle the upload instead
1188
+ Logger.debug("Batch upload cancelled (coroutine cancelled)")
1189
+ throw e // Re-throw to propagate cancellation
1190
+ } catch (e: Exception) {
1191
+ Logger.error("Batch upload failed", e)
1192
+ }
1193
+ }
1194
+
1195
+ private suspend fun stopSessionInternal() {
1196
+ if (!isRecording) return
1197
+
1198
+ try {
1199
+ stopBatchUploadTimer()
1200
+ stopDurationLimitTimer()
1201
+ captureEngine?.stopSession()
1202
+ isRecording = false
1203
+ currentSessionId = null
1204
+ userId = null
1205
+ sessionEvents.clear()
1206
+ } catch (e: Exception) {
1207
+ Logger.error("Failed to stop session internally", e)
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * End the current session with a specific reason.
1213
+ */
1214
+ private fun endSession(reason: EndReason, promise: Promise?) {
1215
+ scope.launch {
1216
+ try {
1217
+ if (!isRecording) {
1218
+ promise?.resolve(createStopResultMap(false, "", false, "Not recording", null))
1219
+ return@launch
1220
+ }
1221
+
1222
+ val sessionId = currentSessionId ?: ""
1223
+ Logger.debug("Ending session due to: $reason")
1224
+
1225
+ // Stop timers
1226
+ stopBatchUploadTimer()
1227
+ stopDurationLimitTimer()
1228
+
1229
+ // Force final capture
1230
+ if (remoteRecordingEnabled) {
1231
+ captureEngine?.forceCaptureWithReason("session_end_${reason.name.lowercase()}")
1232
+ }
1233
+
1234
+ // Stop capture engine
1235
+ captureEngine?.stopSession()
1236
+
1237
+ // Disable touch tracking
1238
+ touchInterceptor?.disableGlobalTracking()
1239
+ keyboardTracker?.stopTracking()
1240
+ textInputTracker?.stopTracking()
1241
+
1242
+ // Build metrics
1243
+ var crashCount = 0
1244
+ var anrCount = 0
1245
+ var errorCount = 0
1246
+ for (event in sessionEvents) {
1247
+ when (event["type"]) {
1248
+ "crash" -> crashCount++
1249
+ "anr" -> anrCount++
1250
+ "error" -> errorCount++
1251
+ }
1252
+ }
1253
+ val durationSeconds = ((System.currentTimeMillis() - sessionStartTime) / 1000).toInt()
1254
+
1255
+ val metrics = mapOf(
1256
+ "crashCount" to crashCount,
1257
+ "anrCount" to anrCount,
1258
+ "errorCount" to errorCount,
1259
+ "durationSeconds" to durationSeconds
1260
+ )
1261
+
1262
+ // Evaluate promotion
1263
+ val promotionResult = uploadManager?.evaluateReplayPromotion(metrics)
1264
+ val isPromoted = promotionResult?.first ?: false
1265
+ val promotionReason = promotionResult?.second ?: "unknown"
1266
+
1267
+ if (isPromoted) {
1268
+ Logger.debug("Session promoted (reason: $promotionReason)")
1269
+ }
1270
+
1271
+ // Upload remaining events
1272
+ val uploadSuccess = uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
1273
+
1274
+ // Send session end signal
1275
+ var endSessionSuccess = sessionEndSent
1276
+ if (!sessionEndSent) {
1277
+ sessionEndSent = true
1278
+ endSessionSuccess = uploadManager?.endSession() ?: false
1279
+ }
1280
+
1281
+ // Clear recovery markers
1282
+ if (endSessionSuccess) {
1283
+ currentSessionId?.let { sid ->
1284
+ uploadManager?.clearSessionRecovery(sid)
1285
+
1286
+ // Mark session as closed in SharedPreferences
1287
+ reactContext.getSharedPreferences("rejourney", 0)
1288
+ .edit()
1289
+ .putLong("rj_session_end_time_$sid", System.currentTimeMillis())
1290
+ .remove("rj_current_session_id")
1291
+ .remove("rj_session_start_time")
1292
+ .apply()
1293
+ }
1294
+ }
1295
+
1296
+ // Clear state
1297
+ isRecording = false
1298
+ currentSessionId = null
1299
+ userId = null
1300
+ sessionEvents.clear()
1301
+
1302
+ // Stop SessionLifecycleService
1303
+ try {
1304
+ val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
1305
+ reactContext.stopService(serviceIntent)
1306
+ Logger.debug("SessionLifecycleService stopped")
1307
+ } catch (e: Exception) {
1308
+ Logger.warning("Failed to stop SessionLifecycleService: ${e.message}")
1309
+ }
1310
+
1311
+ Logger.logSessionEnd(sessionId)
1312
+ promise?.resolve(createStopResultMap(true, sessionId, uploadSuccess && endSessionSuccess, null, null))
1313
+ } catch (e: Exception) {
1314
+ Logger.error("Failed to end session", e)
1315
+ isRecording = false
1316
+ promise?.resolve(createStopResultMap(false, currentSessionId ?: "", false, null, e.message))
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ /**
1322
+ * Synchronous session end for use with runBlocking when app is being killed.
1323
+ *
1324
+ * This is called from onTaskRemoved where we need to complete session end
1325
+ * BEFORE the process is killed. Unlike endSession() which uses scope.launch,
1326
+ * this is a suspend function that runs in the calling coroutine context.
1327
+ */
1328
+ private suspend fun endSessionSynchronous() {
1329
+ if (!isRecording) {
1330
+ Logger.debug("[Rejourney] endSessionSynchronous: Not recording, skipping")
1331
+ return
1332
+ }
1333
+
1334
+ val sessionId = currentSessionId ?: ""
1335
+ Logger.debug("[Rejourney] endSessionSynchronous: Starting for session $sessionId")
1336
+
1337
+ try {
1338
+ // Stop timers (synchronous)
1339
+ stopBatchUploadTimer()
1340
+ stopDurationLimitTimer()
1341
+
1342
+ // Force final capture
1343
+ if (remoteRecordingEnabled) {
1344
+ try {
1345
+ captureEngine?.forceCaptureWithReason("session_end_kill")
1346
+ } catch (e: Exception) {
1347
+ Logger.warning("[Rejourney] Final capture failed: ${e.message}")
1348
+ }
1349
+ }
1350
+
1351
+ // Stop capture engine
1352
+ try {
1353
+ captureEngine?.stopSession()
1354
+ } catch (e: Exception) {
1355
+ Logger.warning("[Rejourney] Stop capture failed: ${e.message}")
1356
+ }
1357
+
1358
+ // Disable tracking
1359
+ try {
1360
+ touchInterceptor?.disableGlobalTracking()
1361
+ keyboardTracker?.stopTracking()
1362
+ textInputTracker?.stopTracking()
1363
+ } catch (e: Exception) {
1364
+ Logger.warning("[Rejourney] Stop tracking failed: ${e.message}")
1365
+ }
1366
+
1367
+ // Build metrics
1368
+ var crashCount = 0
1369
+ var anrCount = 0
1370
+ var errorCount = 0
1371
+ for (event in sessionEvents) {
1372
+ when (event["type"]) {
1373
+ "crash" -> crashCount++
1374
+ "anr" -> anrCount++
1375
+ "error" -> errorCount++
1376
+ }
1377
+ }
1378
+ val durationSeconds = ((System.currentTimeMillis() - sessionStartTime) / 1000).toInt()
1379
+
1380
+ // Upload remaining events - THIS IS THE CRITICAL HTTP CALL
1381
+ Logger.debug("[Rejourney] endSessionSynchronous: Uploading final events (count=${sessionEvents.size})")
1382
+ val uploadSuccess = try {
1383
+ uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
1384
+ } catch (e: Exception) {
1385
+ Logger.warning("[Rejourney] Final upload failed: ${e.message}")
1386
+ false
1387
+ }
1388
+ Logger.debug("[Rejourney] endSessionSynchronous: Upload result=$uploadSuccess")
1389
+
1390
+ // Send session end signal - THIS IS THE CRITICAL /session/end CALL
1391
+ if (!sessionEndSent) {
1392
+ sessionEndSent = true
1393
+ Logger.debug("[Rejourney] endSessionSynchronous: Calling /session/end... (sessionId=$sessionId)")
1394
+
1395
+ // CRITICAL: Ensure uploadManager has the correct sessionId
1396
+ // Prior handleAppBackground may have cleared it, so we restore it here
1397
+ if (sessionId.isNotEmpty()) {
1398
+ uploadManager?.sessionId = sessionId
1399
+ }
1400
+
1401
+ val endSuccess = try {
1402
+ uploadManager?.endSession() ?: false
1403
+ } catch (e: Exception) {
1404
+ Logger.warning("[Rejourney] Session end API call failed: ${e.message}")
1405
+ false
1406
+ }
1407
+ Logger.debug("[Rejourney] endSessionSynchronous: /session/end result=$endSuccess")
1408
+
1409
+ // Clear recovery markers if successful
1410
+ if (endSuccess) {
1411
+ try {
1412
+ uploadManager?.clearSessionRecovery(sessionId)
1413
+ reactContext.getSharedPreferences("rejourney", 0)
1414
+ .edit()
1415
+ .putLong("rj_session_end_time_$sessionId", System.currentTimeMillis())
1416
+ .remove("rj_current_session_id")
1417
+ .remove("rj_session_start_time")
1418
+ .apply()
1419
+ } catch (e: Exception) {
1420
+ Logger.warning("[Rejourney] Clear recovery failed: ${e.message}")
1421
+ }
1422
+ }
1423
+ }
1424
+
1425
+ // Clear state
1426
+ isRecording = false
1427
+ currentSessionId = null
1428
+ userId = null
1429
+ sessionEvents.clear()
1430
+
1431
+ Logger.debug("[Rejourney] endSessionSynchronous: Completed successfully")
1432
+ } catch (e: Exception) {
1433
+ Logger.error("[Rejourney] endSessionSynchronous: Error", e)
1434
+ isRecording = false
1435
+ }
1436
+ }
1437
+
1438
+ /**
1439
+ * Internal method to start recording with options.
1440
+ */
1441
+ private suspend fun startRecordingInternal(
1442
+ options: Map<String, Any?>?,
1443
+ sessionId: String,
1444
+ source: String
1445
+ ) {
1446
+ if (isRecording) {
1447
+ Logger.debug("Already recording, ignoring start request from $source")
1448
+ return
1449
+ }
1450
+
1451
+ // Use saved config from previous session
1452
+ val safeUserId = userId ?: "anonymous"
1453
+ val safeApiUrl = savedApiUrl.ifEmpty { "https://api.rejourney.co" }
1454
+ val safePublicKey = savedPublicKey.ifEmpty { "" }
1455
+ val deviceHash = savedDeviceHash
1456
+
1457
+ // Setup session
1458
+ this.userId = safeUserId
1459
+ currentSessionId = sessionId
1460
+ sessionStartTime = System.currentTimeMillis()
1461
+ totalBackgroundTimeMs = 0
1462
+ sessionEndSent = false
1463
+ sessionEvents.clear()
1464
+ resetSamplingDecision()
1465
+ remoteRecordingEnabled = recordingEnabledByConfig
1466
+ if (hasProjectConfig) {
1467
+ updateRecordingEligibility(projectSampleRate)
1468
+ }
1469
+
1470
+ // Save session ID for crash handler
1471
+ reactContext.getSharedPreferences("rejourney", 0)
1472
+ .edit()
1473
+ .putString("rj_current_session_id", currentSessionId)
1474
+ .apply()
1475
+
1476
+ // Configure upload manager
1477
+ uploadManager?.apply {
1478
+ this.apiUrl = safeApiUrl
1479
+ this.publicKey = safePublicKey
1480
+ this.deviceHash = deviceHash
1481
+ // NUCLEAR FIX: Use setActiveSessionId() to protect from recovery corruption
1482
+ setActiveSessionId(currentSessionId!!)
1483
+ this.userId = safeUserId
1484
+ this.sessionStartTime = this@RejourneyModuleImpl.sessionStartTime
1485
+ resetForNewSession()
1486
+ }
1487
+
1488
+ // Mark session active
1489
+ currentSessionId?.let { sid ->
1490
+ uploadManager?.markSessionActive(sid, sessionStartTime)
1491
+ }
1492
+
1493
+ // Initialize event buffer
1494
+ val pendingDir = File(reactContext.cacheDir, "rj_pending")
1495
+ currentSessionId?.let { sid ->
1496
+ eventBuffer = EventBuffer(reactContext, sid, pendingDir)
1497
+ }
1498
+
1499
+ // Start capture
1500
+ if (remoteRecordingEnabled) {
1501
+ captureEngine?.startSession(currentSessionId!!)
1502
+ }
1503
+
1504
+ // Enable tracking
1505
+ touchInterceptor?.enableGlobalTracking()
1506
+ keyboardTracker?.startTracking()
1507
+ textInputTracker?.startTracking()
1508
+
1509
+ isRecording = true
1510
+ startBatchUploadTimer()
1511
+ startDurationLimitTimer()
1512
+
1513
+ Logger.logSessionStart(currentSessionId ?: "")
1514
+ }
1515
+
1516
+ private fun fetchProjectConfig(publicKey: String, apiUrl: String) {
1517
+ scope.launch(Dispatchers.IO) {
1518
+ try {
1519
+ uploadManager?.fetchProjectConfig { success, config ->
1520
+ if (success && config != null) {
1521
+ hasProjectConfig = true
1522
+
1523
+ config["maxRecordingMinutes"]?.let { maxMinutes ->
1524
+ scope.launch(Dispatchers.Main) {
1525
+ maxRecordingMinutes = (maxMinutes as? Number)?.toInt() ?: 10
1526
+ startDurationLimitTimer()
1527
+ }
1528
+ }
1529
+
1530
+ val sampleRate = (config["sampleRate"] as? Number)?.toInt()
1531
+ if (sampleRate != null) {
1532
+ projectSampleRate = sampleRate.coerceIn(0, 100)
1533
+ }
1534
+
1535
+ val recordingEnabled = (config["recordingEnabled"] as? Boolean) != false
1536
+ scope.launch(Dispatchers.Main) {
1537
+ recordingEnabledByConfig = recordingEnabled
1538
+ if (!recordingEnabled) {
1539
+ Logger.warning("Recording disabled by remote config, stopping capture only")
1540
+ }
1541
+ updateRecordingEligibility(projectSampleRate)
1542
+ }
1543
+
1544
+ config["rejourneyEnabled"]?.let { enabled ->
1545
+ if (enabled == false) {
1546
+ scope.launch(Dispatchers.Main) {
1547
+ Logger.warning("Rejourney disabled by remote config, stopping session")
1548
+ remoteRejourneyEnabled = false
1549
+ stopSessionInternal()
1550
+ }
1551
+ } else {
1552
+ remoteRejourneyEnabled = true
1553
+ }
1554
+ }
1555
+ }
1556
+ }
1557
+ } catch (e: Exception) {
1558
+ Logger.error("Failed to fetch project config", e)
1559
+ }
1560
+ }
1561
+ }
1562
+
1563
+ private fun registerDevice(publicKey: String, apiUrl: String) {
1564
+ scope.launch(Dispatchers.IO) {
1565
+ try {
1566
+ val bundleId = reactContext.packageName
1567
+ deviceAuthManager?.registerDevice(
1568
+ projectKey = publicKey,
1569
+ bundleId = bundleId,
1570
+ platform = "android",
1571
+ sdkVersion = Constants.SDK_VERSION,
1572
+ apiUrl = apiUrl
1573
+ ) { success, credentialId, error ->
1574
+ if (success) {
1575
+ Logger.debug("Device registered: $credentialId")
1576
+
1577
+ // Auth succeeded - reset retry state
1578
+ resetAuthRetryState()
1579
+
1580
+ // Get upload token
1581
+ deviceAuthManager?.getUploadToken { tokenSuccess, token, expiresIn, tokenError ->
1582
+ if (tokenSuccess) {
1583
+ // NOTE: Session recovery is now handled EXCLUSIVELY by WorkManager
1584
+ // The old recoverPendingSessions() approach held a mutex that blocked
1585
+ // all current session uploads. WorkManager.scheduleRecoveryUpload()
1586
+ // runs independently without blocking the current session.
1587
+ // Recovery is already scheduled in onLifecycleStart via UploadWorker.scheduleRecoveryUpload()
1588
+
1589
+ // Check for pending crash reports
1590
+ val crashHandler = CrashHandler.getInstance(reactContext)
1591
+ if (crashHandler.hasPendingCrashReport()) {
1592
+ crashHandler.loadAndPurgePendingCrashReport()?.let { crashReport ->
1593
+ scope.launch(Dispatchers.IO) {
1594
+ uploadManager?.uploadCrashReport(crashReport)
1595
+ }
1596
+ }
1597
+ }
1598
+
1599
+ // Check for pending ANR reports
1600
+ val anrHandler = ANRHandler.getInstance(reactContext)
1601
+ if (anrHandler.hasPendingANRReport()) {
1602
+ anrHandler.loadAndPurgePendingANRReport()?.let { anrReport ->
1603
+ scope.launch(Dispatchers.IO) {
1604
+ uploadManager?.uploadANRReport(anrReport)
1605
+ }
1606
+ }
1607
+ }
1608
+ } else {
1609
+ Logger.warning("Failed to get upload token: $tokenError")
1610
+ }
1611
+ }
1612
+ } else {
1613
+ Logger.warning("Device registration failed: $error")
1614
+ }
1615
+ }
1616
+ } catch (e: Exception) {
1617
+ Logger.error("Device registration error", e)
1618
+ }
1619
+ }
1620
+ }
1621
+
1622
+ // ==================== Activity Lifecycle Callbacks ====================
1623
+ // Note: We prioritize ProcessLifecycleOwner for foreground/background,
1624
+ // but onActivityStopped is critical for immediate background detection.
1625
+
1626
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
1627
+
1628
+ override fun onActivityResumed(activity: Activity) {
1629
+ // CRASH PREVENTION: Wrap in try-catch to never crash host app
1630
+ try {
1631
+ Logger.debug("Activity resumed")
1632
+ cancelScheduledBackground()
1633
+ // Backup foreground detection - ProcessLifecycleOwner may not always fire
1634
+ if (wasInBackground) {
1635
+ handleAppForeground("Activity.onResume")
1636
+ }
1637
+ } catch (e: Exception) {
1638
+ Logger.error("SDK error in onActivityResumed (non-fatal)", e)
1639
+ }
1640
+ }
1641
+
1642
+ override fun onActivityPaused(activity: Activity) {
1643
+ // CRASH PREVENTION: Wrap in try-catch to never crash host app
1644
+ try {
1645
+ Logger.debug("Activity paused (isFinishing=${activity.isFinishing})")
1646
+
1647
+ // Force capture immediately in case app is killed from recents
1648
+ if (remoteRecordingEnabled) {
1649
+ try {
1650
+ captureEngine?.forceCaptureWithReason("app_pausing")
1651
+ } catch (e: Exception) {
1652
+ Logger.warning("Pre-background capture failed: ${e.message}")
1653
+ }
1654
+ }
1655
+
1656
+ // LIGHTWEIGHT BACKGROUND PREP for recents detection
1657
+ // DO NOT call full handleAppBackground here - that stops the capture engine
1658
+ // which causes VideoEncoder race conditions (IllegalStateException: dequeue pending)
1659
+ //
1660
+ // Instead, we:
1661
+ // 1. Set backgroundEntryTime (for 60s timeout calculation)
1662
+ // 2. Flush events to disk (so they're persisted if user swipes to kill)
1663
+ //
1664
+ // Full background handling (stopping capture engine) happens in onActivityStopped/onActivityDestroyed
1665
+ if (!wasInBackground && isRecording) {
1666
+ Logger.debug("[BG] Activity.onPause: Setting background entry time (capture engine still running)")
1667
+ backgroundEntryTime = System.currentTimeMillis()
1668
+
1669
+ // Flush events to disk asynchronously
1670
+ eventBuffer?.flush()
1671
+ Logger.debug("[BG] Activity.onPause: Events flushed to disk, backgroundEntryTime=$backgroundEntryTime")
1672
+ }
1673
+ } catch (e: Exception) {
1674
+ Logger.error("SDK error in onActivityPaused (non-fatal)", e)
1675
+ }
1676
+ }
1677
+
1678
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
1679
+
1680
+ // ==================== DefaultLifecycleObserver (ProcessLifecycleOwner) ====================
1681
+
1682
+ override fun onStart(owner: LifecycleOwner) {
1683
+ // Backup: if Activity callbacks failed/missed, this catches the app start
1684
+ try {
1685
+ Logger.debug("ProcessLifecycleOwner: onStart")
1686
+ cancelScheduledBackground()
1687
+ if (wasInBackground) {
1688
+ handleAppForeground("ProcessLifecycle.onStart")
1689
+ }
1690
+ } catch (e: Exception) {
1691
+ Logger.error("SDK error in ProcessLifecycleOwner.onStart", e)
1692
+ }
1693
+ }
1694
+
1695
+ override fun onStop(owner: LifecycleOwner) {
1696
+ // Backup: catch app background if Activity callbacks missed it
1697
+ try {
1698
+ Logger.debug("ProcessLifecycleOwner: onStop")
1699
+ if (isRecording && !wasInBackground) {
1700
+ // If we're recording and haven't detected background yet, do it now
1701
+ // ProcessLifecycleOwner is already debounced by AndroidX (700ms), so no extra delay needed
1702
+ handleAppBackground("ProcessLifecycle.onStop")
1703
+ }
1704
+ } catch (e: Exception) {
1705
+ Logger.error("SDK error in ProcessLifecycleOwner.onStop", e)
1706
+ }
1707
+ }
1708
+
1709
+ // ==================== ActivityLifecycleCallbacks (Backup/Early Detection) ====================
1710
+
1711
+ override fun onActivityStarted(activity: Activity) {
1712
+ // CRASH PREVENTION: Wrap in try-catch to never crash host app
1713
+ try {
1714
+ Logger.debug("Activity started")
1715
+ cancelScheduledBackground()
1716
+ if (wasInBackground) {
1717
+ handleAppForeground("Activity.onStart")
1718
+ }
1719
+ } catch (e: Exception) {
1720
+ Logger.error("SDK error in onActivityStarted (non-fatal)", e)
1721
+ }
1722
+ }
1723
+
1724
+ override fun onActivityStopped(activity: Activity) {
1725
+ // CRASH PREVENTION: Wrap in try-catch to never crash host app
1726
+ try {
1727
+ if (activity.isChangingConfigurations) {
1728
+ Logger.debug("Activity stopped but changing configurations - skipping background")
1729
+ return
1730
+ }
1731
+
1732
+ if (activity.isFinishing) {
1733
+ // App is closing/killed - IMMEDIATE background handling + FORCE SESSION END
1734
+ // Do not use debounce (scheduleBackground) because app kills (swipes) can terminate process instantly
1735
+ Logger.debug("Activity stopped and finishing - triggering IMMEDIATE background and ENDING SESSION")
1736
+ cancelScheduledBackground()
1737
+ handleAppBackground("Activity.onStop:finishing", shouldEndSession = true)
1738
+ } else {
1739
+ // Normal background - immediate handling (no debounce needed for single activity)
1740
+ // BUT do NOT end session (just flush)
1741
+ Logger.debug("Activity stopped - triggering IMMEDIATE background")
1742
+ cancelScheduledBackground()
1743
+ handleAppBackground("Activity.onStop", shouldEndSession = false)
1744
+ }
1745
+
1746
+ } catch (e: Exception) {
1747
+ Logger.error("SDK error in onActivityStopped (non-fatal)", e)
1748
+ }
1749
+ }
1750
+
1751
+ override fun onActivityDestroyed(activity: Activity) {
1752
+ // CRASH PREVENTION: Wrap in try-catch to never crash host app
1753
+ try {
1754
+ if (activity.isChangingConfigurations) return
1755
+
1756
+ // Redundant backup: ensure background triggered if somehow missed in onStop
1757
+ // FORCE SESSION END
1758
+ Logger.debug("Activity destroyed (isFinishing=${activity.isFinishing}) - triggering IMMEDIATE background")
1759
+ handleAppBackground("Activity.onDestroy", shouldEndSession = true)
1760
+
1761
+ } catch (e: Exception) {
1762
+ Logger.error("SDK error in onActivityDestroyed (non-fatal)", e)
1763
+ }
1764
+ }
1765
+
1766
+ private fun scheduleBackground(source: String) {
1767
+ if (wasInBackground || backgroundScheduled) return
1768
+
1769
+ // NOTE: This method is now kept mainly for onPause if we decide to use it,
1770
+ // or for legacy debounce logic. Currently onActivityStopped uses immediate handling.
1771
+ backgroundScheduled = true
1772
+ backgroundEntryTime = System.currentTimeMillis()
1773
+
1774
+ Logger.debug("Scheduling background in 50ms (source=$source)")
1775
+
1776
+ val runnable = Runnable {
1777
+ backgroundScheduled = false
1778
+ handleAppBackground("$source:debounced")
1779
+ }
1780
+
1781
+ scheduledBackgroundRunnable = runnable
1782
+ mainHandler.postDelayed(runnable, 50L) // Reduced to 50ms
1783
+ }
1784
+
1785
+ private fun cancelScheduledBackground() {
1786
+ if (!backgroundScheduled) return
1787
+ scheduledBackgroundRunnable?.let { mainHandler.removeCallbacks(it) }
1788
+ scheduledBackgroundRunnable = null
1789
+ backgroundScheduled = false
1790
+ if (!wasInBackground) {
1791
+ backgroundEntryTime = 0L
1792
+ }
1793
+ }
1794
+
1795
+ /**
1796
+ * Foreground handler - handles return from background with session timeout logic.
1797
+ *
1798
+ * Session Behavior (matching iOS):
1799
+ * - Background < 60s: Resume same session, accumulate background time for billing exclusion
1800
+ * - Background >= 60s: End old session, start new session
1801
+ * - App killed in background: Auto-finalized by backend worker after 60s
1802
+ */
1803
+ private fun handleAppForeground(source: String) {
1804
+ if (!wasInBackground || backgroundEntryTime == 0L) {
1805
+ Logger.debug("[FG] Not returning from background, skipping")
1806
+ return
1807
+ }
1808
+
1809
+ val bgDurationMs = System.currentTimeMillis() - backgroundEntryTime
1810
+ val bgDurationSec = bgDurationMs / 1000.0
1811
+ val sessionTimeoutMs = (Constants.BACKGROUND_SESSION_TIMEOUT * 1000).toLong()
1812
+ val thresholdSec = Constants.BACKGROUND_SESSION_TIMEOUT
1813
+
1814
+ Logger.debug("[FG] === APP FOREGROUND ($source) ===")
1815
+ Logger.debug("[FG] Was in background for ${String.format("%.1f", bgDurationSec)}s")
1816
+ Logger.debug("[FG] Session timeout threshold: ${thresholdSec}s")
1817
+ Logger.debug("[FG] Current totalBackgroundTimeMs: $totalBackgroundTimeMs")
1818
+
1819
+ // Reset background tracking state immediately (like iOS)
1820
+ wasInBackground = false
1821
+ backgroundEntryTime = 0
1822
+
1823
+ if (bgDurationMs >= sessionTimeoutMs) {
1824
+ // === TIMEOUT CASE: End old session, start new one ===
1825
+ Logger.debug("[FG] TIMEOUT: ${bgDurationSec}s >= ${thresholdSec}s → Creating NEW session")
1826
+ handleSessionTimeoutOnForeground(bgDurationMs, source)
1827
+ } else {
1828
+ // === SHORT BACKGROUND: Resume same session ===
1829
+ Logger.debug("[FG] SHORT BACKGROUND: ${bgDurationSec}s < ${thresholdSec}s → Resuming SAME session")
1830
+ handleShortBackgroundResume(bgDurationMs, source)
1831
+ }
1832
+ }
1833
+
1834
+ /**
1835
+ * Handle session timeout after extended background (>= 60s).
1836
+ * Ends the old session and starts a fresh one.
1837
+ *
1838
+ * CRITICAL: Uses backgroundScope + NonCancellable to ensure recovery completes
1839
+ * even if the app goes to background again during this process.
1840
+ */
1841
+ private fun handleSessionTimeoutOnForeground(bgDurationMs: Long, source: String) {
1842
+ val oldSessionId = currentSessionId ?: return
1843
+ val wasRecording = isRecording
1844
+
1845
+ if (!wasRecording) {
1846
+ Logger.debug("Session timeout but wasn't recording - ignoring")
1847
+ return
1848
+ }
1849
+
1850
+ Logger.debug("SESSION TIMEOUT: Ending session $oldSessionId after ${bgDurationMs/1000}s in background")
1851
+
1852
+ // Add final background time to accumulated total before ending
1853
+ totalBackgroundTimeMs += bgDurationMs
1854
+ uploadManager?.totalBackgroundTimeMs = totalBackgroundTimeMs
1855
+
1856
+ // Stop all capture/tracking immediately (synchronous, like iOS)
1857
+ try {
1858
+ stopBatchUploadTimer()
1859
+ stopDurationLimitTimer()
1860
+ captureEngine?.stopSession()
1861
+ touchInterceptor?.disableGlobalTracking()
1862
+ keyboardTracker?.stopTracking()
1863
+ textInputTracker?.stopTracking()
1864
+ } catch (e: Exception) {
1865
+ Logger.warning("Error stopping capture during session timeout: ${e.message}")
1866
+ }
1867
+
1868
+ // Mark as not recording to prevent race conditions
1869
+ isRecording = false
1870
+
1871
+ // Handle old session end and new session start asynchronously using backgroundScope
1872
+ // which survives independently of the main scope and won't be cancelled on background
1873
+ backgroundScope.launch {
1874
+ // Use NonCancellable context to ensure critical recovery operations complete
1875
+ // even if the coroutine is cancelled (app goes to background again)
1876
+ withContext(NonCancellable) {
1877
+ try {
1878
+ // CRITICAL: Ensure auth token is valid before uploading
1879
+ // Token may have expired during the 60+ seconds in background
1880
+ try {
1881
+ DeviceAuthManager.getInstance(reactContext).ensureValidToken()
1882
+ } catch (e: Exception) {
1883
+ Logger.warning("Failed to refresh auth token during session timeout: ${e.message}")
1884
+ }
1885
+
1886
+ // Add session_timeout event to old session's events
1887
+ val timeoutEvent = mapOf(
1888
+ "type" to EventType.SESSION_TIMEOUT,
1889
+ "timestamp" to System.currentTimeMillis(),
1890
+ "backgroundDuration" to bgDurationMs,
1891
+ "timeoutThreshold" to (Constants.BACKGROUND_SESSION_TIMEOUT * 1000).toLong(),
1892
+ "reason" to "background_timeout"
1893
+ )
1894
+ sessionEvents.add(timeoutEvent)
1895
+
1896
+ // Upload old session's events as final and call session/end
1897
+ val finalEvents = sessionEvents.toList()
1898
+ sessionEvents.clear()
1899
+
1900
+ if (finalEvents.isNotEmpty()) {
1901
+ try {
1902
+ uploadManager?.uploadBatch(finalEvents, isFinal = true)
1903
+ } catch (e: Exception) {
1904
+ Logger.warning("Failed to upload final events during session timeout: ${e.message}")
1905
+ }
1906
+ }
1907
+
1908
+ // End the old session (calls /session/end which triggers promotion)
1909
+ // CRITICAL: Pass oldSessionId explicitly since uploadManager.sessionId may be reset
1910
+ var endSessionSuccess = false
1911
+ if (!sessionEndSent) {
1912
+ sessionEndSent = true
1913
+ try {
1914
+ endSessionSuccess = uploadManager?.endSession(sessionIdOverride = oldSessionId) ?: false
1915
+ } catch (e: Exception) {
1916
+ Logger.warning("Failed to end old session: ${e.message}")
1917
+ }
1918
+ }
1919
+
1920
+ // Clear recovery markers for old session
1921
+ try {
1922
+ uploadManager?.clearSessionRecovery(oldSessionId)
1923
+ } catch (e: Exception) {
1924
+ Logger.warning("Failed to clear session recovery: ${e.message}")
1925
+ }
1926
+
1927
+ if (endSessionSuccess) {
1928
+ Logger.debug("Old session $oldSessionId ended successfully")
1929
+ } else {
1930
+ Logger.warning("Old session $oldSessionId end signal failed - will be recovered on next launch")
1931
+ }
1932
+
1933
+ // === START NEW SESSION ===
1934
+ val timestamp = System.currentTimeMillis()
1935
+ val shortUuid = UUID.randomUUID().toString().take(8).uppercase()
1936
+ val newSessionId = "session_${timestamp}_$shortUuid"
1937
+
1938
+ // Reset state for new session
1939
+ currentSessionId = newSessionId
1940
+ sessionStartTime = timestamp
1941
+ totalBackgroundTimeMs = 0
1942
+ sessionEndSent = false
1943
+
1944
+ // Reset upload manager for new session
1945
+ uploadManager?.let { um ->
1946
+ // NUCLEAR FIX: Use setActiveSessionId() to update both sessionId AND activeSessionId
1947
+ // This ensures the new session doesn't get merged into the old one's recovery path
1948
+ um.setActiveSessionId(newSessionId)
1949
+
1950
+ um.sessionStartTime = timestamp
1951
+ um.totalBackgroundTimeMs = 0
1952
+
1953
+ // FIX: Synchronize user identity and config to UploadManager
1954
+ // This matches iOS behavior and ensures robustness if memory was cleared
1955
+ um.userId = userId ?: "anonymous"
1956
+
1957
+ // Restore saved config if available
1958
+ if (savedDeviceHash.isNotEmpty()) um.deviceHash = savedDeviceHash
1959
+ if (savedPublicKey.isNotEmpty()) um.publicKey = savedPublicKey
1960
+ if (savedApiUrl.isNotEmpty()) um.apiUrl = savedApiUrl
1961
+
1962
+ // CRITICAL: Create session metadata file for crash recovery
1963
+ um.markSessionActive(newSessionId, timestamp)
1964
+ }
1965
+
1966
+ // CRITICAL: Save new session ID to SharedPreferences for unclosed session detection
1967
+ // Use commit() instead of apply() to ensure synchronous write before app kill
1968
+ reactContext.getSharedPreferences("rejourney", 0)
1969
+ .edit()
1970
+ .putString("rj_current_session_id", newSessionId)
1971
+ .putLong("rj_session_start_time", timestamp)
1972
+ .commit()
1973
+
1974
+ // CRITICAL: Re-initialize EventBuffer for new session
1975
+ // Without this, events are written to wrong session's file
1976
+ val pendingDir = java.io.File(reactContext.cacheDir, "rj_pending")
1977
+ eventBuffer = EventBuffer(reactContext, newSessionId, pendingDir)
1978
+
1979
+ // Start capture for new session (run on main thread for UI safety)
1980
+ withContext(Dispatchers.Main) {
1981
+ try {
1982
+ resetSamplingDecision()
1983
+ remoteRecordingEnabled = recordingEnabledByConfig
1984
+ if (hasProjectConfig) {
1985
+ updateRecordingEligibility(projectSampleRate)
1986
+ }
1987
+
1988
+ if (remoteRecordingEnabled) {
1989
+ captureEngine?.startSession(newSessionId)
1990
+ }
1991
+
1992
+ // Re-enable tracking
1993
+ touchInterceptor?.enableGlobalTracking()
1994
+ keyboardTracker?.startTracking()
1995
+ textInputTracker?.startTracking()
1996
+
1997
+ // CRITICAL: Restart SessionLifecycleService for the new session
1998
+ // The system destroys it after ~60 seconds in background
1999
+ // Without this, onTaskRemoved won't be called and session won't end properly
2000
+ try {
2001
+ val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
2002
+ reactContext.startService(serviceIntent)
2003
+ Logger.debug("SessionLifecycleService restarted for new session after timeout")
2004
+ } catch (e: Exception) {
2005
+ Logger.warning("Failed to restart SessionLifecycleService: ${e.message}")
2006
+ }
2007
+ } catch (e: Exception) {
2008
+ Logger.warning("Error starting capture for new session: ${e.message}")
2009
+ }
2010
+ }
2011
+
2012
+ isRecording = true
2013
+
2014
+ // Add session_start event for new session
2015
+ val sessionStartEvent = mapOf(
2016
+ "type" to EventType.SESSION_START,
2017
+ "timestamp" to System.currentTimeMillis(),
2018
+ "previousSessionId" to oldSessionId,
2019
+ "backgroundDuration" to bgDurationMs,
2020
+ "reason" to "resumed_after_background_timeout",
2021
+ "userId" to (userId ?: "anonymous")
2022
+ )
2023
+ addEventWithPersistence(sessionStartEvent)
2024
+
2025
+ // Start timers for new session
2026
+ startBatchUploadTimer()
2027
+ startDurationLimitTimer()
2028
+
2029
+ // Trigger immediate upload to register new session
2030
+ delay(100)
2031
+ try {
2032
+ performBatchUpload()
2033
+ } catch (e: Exception) {
2034
+ Logger.warning("Failed to perform immediate batch upload: ${e.message}")
2035
+ }
2036
+
2037
+ Logger.debug("New session $newSessionId started (previous: $oldSessionId)")
2038
+
2039
+ } catch (e: CancellationException) {
2040
+ // Coroutine was cancelled but NonCancellable should prevent this
2041
+ // Log as warning, not error - this is expected if app is killed
2042
+ Logger.warning("Session timeout recovery interrupted: ${e.message}")
2043
+ // Ensure recording state is consistent
2044
+ isRecording = true
2045
+ startBatchUploadTimer()
2046
+ } catch (e: Exception) {
2047
+ Logger.error("Failed to handle session timeout", e)
2048
+ // Attempt recovery - restart recording
2049
+ isRecording = true
2050
+ startBatchUploadTimer()
2051
+ }
2052
+ }
2053
+ }
2054
+ }
2055
+
2056
+ /**
2057
+ * Handle short background return (< 60s) - resume same session.
2058
+ */
2059
+ private fun handleShortBackgroundResume(bgDurationMs: Long, source: String) {
2060
+ // Accumulate background time for billing exclusion
2061
+ val previousBgTime = totalBackgroundTimeMs
2062
+ totalBackgroundTimeMs += bgDurationMs
2063
+ uploadManager?.totalBackgroundTimeMs = totalBackgroundTimeMs
2064
+ currentSessionId?.let { sid ->
2065
+ uploadManager?.updateSessionRecoveryMeta(sid)
2066
+ }
2067
+
2068
+ Logger.debug("[FG] Background time: $previousBgTime + $bgDurationMs = $totalBackgroundTimeMs ms")
2069
+ Logger.debug("[FG] Resuming session: $currentSessionId")
2070
+
2071
+ if (!isRecording || currentSessionId.isNullOrEmpty()) {
2072
+ Logger.debug("[FG] Not recording or no session - skipping resume")
2073
+ return
2074
+ }
2075
+
2076
+ // Log foreground event for replay player
2077
+ addEventWithPersistence(
2078
+ mapOf(
2079
+ "type" to EventType.APP_FOREGROUND,
2080
+ "timestamp" to System.currentTimeMillis(),
2081
+ "backgroundDurationMs" to bgDurationMs,
2082
+ "totalBackgroundTimeMs" to totalBackgroundTimeMs,
2083
+ "source" to source
2084
+ )
2085
+ )
2086
+
2087
+ // Resume capture and tracking
2088
+ if (remoteRecordingEnabled) {
2089
+ try {
2090
+ captureEngine?.startSession(currentSessionId!!)
2091
+ captureEngine?.forceCaptureWithReason("app_foreground")
2092
+ } catch (e: Exception) {
2093
+ Logger.warning("Foreground capture resume failed: ${e.message}")
2094
+ }
2095
+ }
2096
+
2097
+ touchInterceptor?.enableGlobalTracking()
2098
+ keyboardTracker?.startTracking()
2099
+ textInputTracker?.startTracking()
2100
+
2101
+ startBatchUploadTimer()
2102
+ startDurationLimitTimer()
2103
+ }
2104
+
2105
+ /**
2106
+ * Background handler (aligned with iOS + replay player expectations).
2107
+ *
2108
+ * We treat background as a pause:
2109
+ * - log app_background
2110
+ * - flush pending data (NOT final)
2111
+ * - stop capture/tracking while backgrounded
2112
+ *
2113
+ * If the process is killed (shouldEndSession=true), crash-safe persistence + next-launch recovery will
2114
+ * upload remaining pending data and close the session via session/end.
2115
+ */
2116
+ private fun handleAppBackground(source: String, shouldEndSession: Boolean = false) {
2117
+ // Prevent duplicate background handling (unless forcing end)
2118
+ if (wasInBackground && !shouldEndSession) {
2119
+ Logger.debug("[BG] Already in background, skipping duplicate handling")
2120
+ return
2121
+ }
2122
+
2123
+ Logger.debug("[BG] === APP BACKGROUND ($source) ===")
2124
+ Logger.debug("[BG] isRecording=$isRecording, isShuttingDown=$isShuttingDown, sessionId=$currentSessionId, shouldEndSession=$shouldEndSession")
2125
+
2126
+ if (isRecording && !isShuttingDown) {
2127
+ wasInBackground = true
2128
+ // backgroundEntryTime is already set by debounce scheduling
2129
+ if (backgroundEntryTime == 0L) {
2130
+ backgroundEntryTime = System.currentTimeMillis()
2131
+ }
2132
+ Logger.debug("[BG] backgroundEntryTime set to $backgroundEntryTime")
2133
+ Logger.debug("[BG] Current totalBackgroundTimeMs=$totalBackgroundTimeMs")
2134
+
2135
+ // Stop timers (but don't cancel in-progress uploads)
2136
+ stopBatchUploadTimer()
2137
+ stopDurationLimitTimer()
2138
+
2139
+ // Stop tracking
2140
+ keyboardTracker?.stopTracking()
2141
+ textInputTracker?.stopTracking()
2142
+ touchInterceptor?.disableGlobalTracking()
2143
+
2144
+ // Add background event
2145
+ val event = mapOf(
2146
+ "type" to EventType.APP_BACKGROUND,
2147
+ "timestamp" to System.currentTimeMillis()
2148
+ )
2149
+ addEventWithPersistence(event)
2150
+
2151
+ // CRITICAL: Ensure all in-memory events are written to disk before scheduling upload
2152
+ // EventBuffer uses async writes, so we need to flush to ensure all writes complete
2153
+ Logger.debug("[BG] ===== ENSURING ALL EVENTS ARE PERSISTED TO DISK =====")
2154
+ Logger.debug("[BG] In-memory events count: ${sessionEvents.size}")
2155
+ Logger.debug("[BG] Event types in memory: ${sessionEvents.map { it["type"] }.joinToString(", ")}")
2156
+
2157
+ // Log event buffer state before flush
2158
+ eventBuffer?.let { buffer ->
2159
+ Logger.debug("[BG] EventBuffer state: eventCount=${buffer.eventCount}, fileExists=${File(reactContext.cacheDir, "rj_pending/$currentSessionId/events.jsonl").exists()}")
2160
+ } ?: Logger.warning("[BG] EventBuffer is NULL - cannot flush events!")
2161
+
2162
+ // Flush all pending writes to disk
2163
+ // This drains the async write queue and ensures all events are on disk
2164
+ val flushStartTime = System.currentTimeMillis()
2165
+ val flushSuccess = eventBuffer?.flush() ?: false
2166
+ val flushDuration = System.currentTimeMillis() - flushStartTime
2167
+
2168
+ if (flushSuccess) {
2169
+ Logger.debug("[BG] ✅ Events flushed to disk successfully in ${flushDuration}ms")
2170
+ Logger.debug("[BG] In-memory events: ${sessionEvents.size}, EventBuffer eventCount: ${eventBuffer?.eventCount ?: 0}")
2171
+
2172
+ // Verify file exists and has content
2173
+ val eventsFile = File(reactContext.cacheDir, "rj_pending/$currentSessionId/events.jsonl")
2174
+ if (eventsFile.exists()) {
2175
+ val fileSize = eventsFile.length()
2176
+ Logger.debug("[BG] Events file exists: size=$fileSize bytes, path=${eventsFile.absolutePath}")
2177
+ } else {
2178
+ Logger.error("[BG] ⚠️ Events file does NOT exist after flush! path=${eventsFile.absolutePath}")
2179
+ }
2180
+ } else {
2181
+ Logger.error("[BG] ❌ FAILED to flush events to disk - some events may be lost!")
2182
+ Logger.error("[BG] Flush duration: ${flushDuration}ms")
2183
+ }
2184
+ Logger.debug("[BG] ===== EVENT PERSISTENCE CHECK COMPLETE =====")
2185
+
2186
+ // Stop capture engine while backgrounded (triggers final segment flush and hierarchy upload)
2187
+ if (remoteRecordingEnabled) {
2188
+ Logger.debug("[BG] ===== STOPPING CAPTURE ENGINE =====")
2189
+
2190
+ if (shouldEndSession) {
2191
+ // Kill scenario: use emergency flush to save crash metadata and stop ASAP
2192
+ Logger.debug("[BG] Force kill detected - using emergency flush")
2193
+ captureEngine?.emergencyFlush()
2194
+ // Continue to stopSession for hierarchy upload and other cleanup
2195
+ }
2196
+
2197
+ Logger.debug("[BG] Stopping capture engine for background (sessionId=$currentSessionId)")
2198
+ captureEngine?.stopSession()
2199
+ Logger.debug("[BG] Capture engine stopSession() called")
2200
+ }
2201
+
2202
+ // Update session recovery meta so WorkManager can find the session
2203
+ currentSessionId?.let { sid ->
2204
+ uploadManager?.updateSessionRecoveryMeta(sid)
2205
+ Logger.debug("[BG] Session recovery metadata updated for: $sid")
2206
+ }
2207
+
2208
+ // ===== SIMPLIFIED UPLOAD: WorkManager Only =====
2209
+ // Industry-standard approach: persist first (done above), then schedule background worker.
2210
+ // NO synchronous uploads - they cause ANRs and mutex contention with recovery.
2211
+ // WorkManager is reliable for background uploads when properly configured.
2212
+ currentSessionId?.let { sid ->
2213
+ Logger.debug("[BG] ===== SCHEDULING WORKMANAGER UPLOAD =====")
2214
+ Logger.debug("[BG] Session: $sid, Events persisted: ${eventBuffer?.eventCount ?: 0}, isFinal: $shouldEndSession")
2215
+
2216
+ // Clear in-memory events since they're persisted to disk
2217
+ // WorkManager will read from disk
2218
+ sessionEvents.clear()
2219
+
2220
+ // Schedule expedited upload via WorkManager
2221
+ UploadWorker.scheduleUpload(
2222
+ context = reactContext,
2223
+ sessionId = sid,
2224
+ isFinal = shouldEndSession, // Pass shouldEndSession as isFinal
2225
+ expedited = true // Request expedited execution
2226
+ )
2227
+ Logger.debug("[BG] ✅ WorkManager upload scheduled for session: $sid")
2228
+
2229
+ // NEW: Best-effort immediate upload (Fire-and-Forget)
2230
+ // Try to upload immediately while app is still alive in memory.
2231
+ // If this succeeds, WorkManager will find nothing to do (which is fine).
2232
+ // If this fails/gets killed, WorkManager will pick it up.
2233
+ // This mimics iOS "beginBackgroundTask" pattern.
2234
+ scope.launch(Dispatchers.IO) {
2235
+ try {
2236
+ Logger.debug("[BG] 🚀 Attempting immediate best-effort upload for $sid")
2237
+
2238
+ // Create a temporary UploadManager because the main one's state is complex
2239
+ // We use the same parameters as WorkManager creates
2240
+ val authManager = DeviceAuthManager.getInstance(reactContext)
2241
+ val apiUrl = authManager.getCurrentApiUrl() ?: "https://api.rejourney.co"
2242
+
2243
+ val bgUploader = com.rejourney.network.UploadManager(reactContext, apiUrl).apply {
2244
+ this.sessionId = sid
2245
+ this.setActiveSessionId(sid) // CRITICAL: Set active session ID
2246
+ this.publicKey = authManager.getCurrentPublicKey() ?: ""
2247
+ this.deviceHash = authManager.getCurrentDeviceHash() ?: ""
2248
+ this.sessionStartTime = uploadManager?.sessionStartTime ?: 0L
2249
+ this.totalBackgroundTimeMs = uploadManager?.totalBackgroundTimeMs ?: 0L
2250
+ }
2251
+
2252
+ // Read events from disk since we flushed them
2253
+ val eventBufferDir = File(reactContext.cacheDir, "rj_pending/$sid")
2254
+ val eventsFile = File(eventBufferDir, "events.jsonl")
2255
+
2256
+ if (eventsFile.exists()) {
2257
+ // Read events - duplicated logic from UploadWorker but necessary for successful off-main-thread upload
2258
+ val events = mutableListOf<Map<String, Any?>>()
2259
+ eventsFile.bufferedReader().useLines { lines ->
2260
+ lines.forEach { line ->
2261
+ if (line.isNotBlank()) {
2262
+ try {
2263
+ val json = org.json.JSONObject(line)
2264
+ val map = mutableMapOf<String, Any?>()
2265
+ json.keys().forEach { key ->
2266
+ map[key] = json.opt(key)
2267
+ }
2268
+ events.add(map)
2269
+ } catch (e: Exception) { }
2270
+ }
2271
+ }
2272
+ }
2273
+
2274
+ if (events.isNotEmpty()) {
2275
+ Logger.debug("[BG] Immediate upload: found ${events.size} events")
2276
+ val success = bgUploader.uploadBatch(events, isFinal = shouldEndSession)
2277
+ if (success) {
2278
+ Logger.debug("[BG] ✅ Immediate upload SUCCESS! Cleaning up disk...")
2279
+ // Clean up so WorkManager doesn't re-upload
2280
+ eventsFile.delete()
2281
+ File(eventBufferDir, "buffer_meta.json").delete()
2282
+
2283
+ // IF FINAL, END SESSION IMMEDIATELY
2284
+ if (shouldEndSession) {
2285
+ Logger.debug("[BG] Immediate upload was final, ending session...")
2286
+ bgUploader.endSession()
2287
+ }
2288
+ } else {
2289
+ Logger.warning("[BG] Immediate upload failed - leaving for WorkManager")
2290
+ }
2291
+ } else if (shouldEndSession) {
2292
+ // Even if no events, if it's final, we should try to end session
2293
+ Logger.debug("[BG] No events but shouldEndSession=true, ending session...")
2294
+ bgUploader.endSession()
2295
+ }
2296
+ } else if (shouldEndSession) {
2297
+ // Even if no event file, if it's final, we should try to end session
2298
+ Logger.debug("[BG] No event file but shouldEndSession=true, ending session...")
2299
+ bgUploader.endSession()
2300
+ }
2301
+ } catch (e: Exception) {
2302
+ Logger.error("[BG] Immediate upload error: ${e.message} - WorkManager will handle it")
2303
+ }
2304
+ }
2305
+ } // End of currentSessionId?.let
2306
+ } else {
2307
+ Logger.debug("[BG] Skipping background handling (isRecording=$isRecording, isShuttingDown=$isShuttingDown)")
2308
+ }
2309
+ }
2310
+
2311
+ // ==================== TouchInterceptorDelegate ====================
2312
+
2313
+ override fun onTouchEvent(event: MotionEvent, gestureType: String?) {
2314
+ // We rely primarily on onGestureRecognized, but can add raw touches if needed.
2315
+ // For now, to match iOS "touches visited", we can treat simple taps here if needed,
2316
+ // but gestureClassifier usually handles it.
2317
+ }
2318
+
2319
+ override fun onGestureRecognized(gestureType: String, x: Float, y: Float, details: Map<String, Any?>) {
2320
+ if (!isRecording) return
2321
+
2322
+ try {
2323
+ val timestamp = System.currentTimeMillis()
2324
+
2325
+ // Build touches array matching iOS format for web player compatibility
2326
+ // Web player filters events without touches array (e.touches.length > 0)
2327
+ val touchPoint = mapOf(
2328
+ "x" to x,
2329
+ "y" to y,
2330
+ "timestamp" to timestamp,
2331
+ "force" to (details["force"] ?: 0f)
2332
+ )
2333
+
2334
+ val eventMap = mapOf(
2335
+ "type" to EventType.GESTURE,
2336
+ "timestamp" to timestamp,
2337
+ "gestureType" to gestureType,
2338
+ "touches" to listOf(touchPoint), // Required by web player TouchOverlay
2339
+ "duration" to (details["duration"] ?: 0),
2340
+ "targetLabel" to details["targetLabel"],
2341
+ "x" to x, // Keep for backwards compatibility
2342
+ "y" to y,
2343
+ "details" to details
2344
+ )
2345
+
2346
+ // Debug logging to verify touch events are captured correctly for web overlay
2347
+ Logger.debug("[TOUCH] Gesture recorded: type=$gestureType, x=$x, y=$y, touches=${listOf(touchPoint)}")
2348
+
2349
+ addEventWithPersistence(eventMap)
2350
+ } catch (e: Exception) {
2351
+ Logger.error("Failed to record gesture", e)
2352
+ }
2353
+ }
2354
+
2355
+ override fun onGestureWithTouchPath(
2356
+ gestureType: String,
2357
+ touches: List<Map<String, Any>>,
2358
+ duration: Long,
2359
+ targetLabel: String?
2360
+ ) {
2361
+ if (!isRecording) return
2362
+
2363
+ try {
2364
+ val timestamp = System.currentTimeMillis()
2365
+ val firstTouch = touches.firstOrNull()
2366
+ val x = (firstTouch?.get("x") as? Number)?.toFloat() ?: 0f
2367
+ val y = (firstTouch?.get("y") as? Number)?.toFloat() ?: 0f
2368
+
2369
+ val eventMap = mapOf(
2370
+ "type" to EventType.GESTURE,
2371
+ "timestamp" to timestamp,
2372
+ "gestureType" to gestureType,
2373
+ "touches" to touches,
2374
+ "duration" to duration,
2375
+ "targetLabel" to targetLabel,
2376
+ "x" to x,
2377
+ "y" to y,
2378
+ "details" to mapOf(
2379
+ "duration" to duration,
2380
+ "targetLabel" to targetLabel
2381
+ )
2382
+ )
2383
+
2384
+ Logger.debug("[TOUCH] Gesture recorded: type=$gestureType, touches=${touches.size}")
2385
+ addEventWithPersistence(eventMap)
2386
+ } catch (e: Exception) {
2387
+ Logger.error("Failed to record gesture", e)
2388
+ }
2389
+ }
2390
+
2391
+ override fun onRageTap(tapCount: Int, x: Float, y: Float) {
2392
+ if (!isRecording) return
2393
+
2394
+ try {
2395
+ val timestamp = System.currentTimeMillis()
2396
+
2397
+ // Build touches array matching iOS format for web player compatibility
2398
+ val touchPoint = mapOf(
2399
+ "x" to x,
2400
+ "y" to y,
2401
+ "timestamp" to timestamp,
2402
+ "force" to 0f
2403
+ )
2404
+
2405
+ val eventMap = mapOf(
2406
+ "type" to EventType.GESTURE, // Use gesture type for web player compatibility
2407
+ "timestamp" to timestamp,
2408
+ "gestureType" to "rage_tap",
2409
+ "touches" to listOf(touchPoint), // Required by web player TouchOverlay
2410
+ "tapCount" to tapCount,
2411
+ "x" to x,
2412
+ "y" to y
2413
+ )
2414
+ addEventWithPersistence(eventMap)
2415
+ } catch (e: Exception) {
2416
+ Logger.error("Failed to record rage tap", e)
2417
+ }
2418
+ }
2419
+
2420
+ override fun isCurrentlyRecording(): Boolean = isRecording
2421
+
2422
+ override fun isKeyboardCurrentlyVisible(): Boolean = isKeyboardVisible
2423
+
2424
+ override fun currentKeyboardHeight(): Int = lastKeyboardHeight
2425
+
2426
+ // ==================== NetworkMonitorListener ====================
2427
+
2428
+ override fun onNetworkChanged(quality: com.rejourney.network.NetworkQuality) {
2429
+ if (!isRecording) return
2430
+
2431
+ val qualityMap = quality.toMap()
2432
+ val networkType = qualityMap["networkType"] as? String ?: "none"
2433
+ val cellularGeneration = qualityMap["cellularGeneration"] as? String ?: "unknown"
2434
+
2435
+ val eventMap = mutableMapOf<String, Any?>(
2436
+ "type" to "network_change",
2437
+ "timestamp" to System.currentTimeMillis(),
2438
+ "status" to if (networkType == "none") "disconnected" else "connected",
2439
+ "networkType" to networkType,
2440
+ "isConstrained" to (qualityMap["isConstrained"] as? Boolean ?: false),
2441
+ "isExpensive" to (qualityMap["isExpensive"] as? Boolean ?: false)
2442
+ )
2443
+
2444
+ if (cellularGeneration != "unknown") {
2445
+ eventMap["cellularGeneration"] = cellularGeneration
2446
+ }
2447
+
2448
+ addEventWithPersistence(eventMap)
2449
+ }
2450
+
2451
+ // ==================== KeyboardTrackerListener ====================
2452
+
2453
+ override fun onKeyboardShown(keyboardHeight: Int) {
2454
+ if (!isRecording) return
2455
+
2456
+ Logger.debug("[KEYBOARD] Keyboard shown (height=$keyboardHeight)")
2457
+ isKeyboardVisible = true
2458
+ lastKeyboardHeight = keyboardHeight
2459
+
2460
+ val eventMap = mapOf(
2461
+ "type" to EventType.KEYBOARD_SHOW,
2462
+ "timestamp" to System.currentTimeMillis(),
2463
+ "keyboardHeight" to keyboardHeight
2464
+ )
2465
+ addEventWithPersistence(eventMap)
2466
+
2467
+ // Schedule capture after keyboard settles
2468
+ captureEngine?.notifyKeyboardEvent("keyboard_shown")
2469
+ }
2470
+
2471
+ override fun onKeyboardHidden() {
2472
+ if (!isRecording) return
2473
+
2474
+ Logger.debug("[KEYBOARD] Keyboard hidden (keyPresses=$keyPressCount)")
2475
+ isKeyboardVisible = false
2476
+
2477
+ // Match iOS/player behavior: emit a recent typing signal if we recorded keypresses
2478
+ if (keyPressCount > 0) {
2479
+ addEventWithPersistence(
2480
+ mapOf(
2481
+ "type" to EventType.KEYBOARD_TYPING,
2482
+ "timestamp" to System.currentTimeMillis(),
2483
+ "keyPressCount" to keyPressCount
2484
+ )
2485
+ )
2486
+ }
2487
+
2488
+ val eventMap = mapOf(
2489
+ "type" to EventType.KEYBOARD_HIDE,
2490
+ "timestamp" to System.currentTimeMillis(),
2491
+ "keyPressCount" to keyPressCount
2492
+ )
2493
+ addEventWithPersistence(eventMap)
2494
+
2495
+ // Reset key press count when keyboard hides
2496
+ keyPressCount = 0
2497
+
2498
+ // Schedule capture after keyboard settles
2499
+ captureEngine?.notifyKeyboardEvent("keyboard_hidden")
2500
+ }
2501
+
2502
+ override fun onKeyPress() {
2503
+ keyPressCount++
2504
+ }
2505
+
2506
+ // ==================== TextInputTrackerListener ====================
2507
+
2508
+ override fun onTextChanged(characterCount: Int) {
2509
+ if (!isRecording) return
2510
+ if (characterCount <= 0) return
2511
+
2512
+ // Accumulate key presses
2513
+ keyPressCount += characterCount
2514
+
2515
+ // Emit typing events so the player can animate typing indicators.
2516
+ // (No actual text content is captured.)
2517
+ if (isKeyboardVisible) {
2518
+ addEventWithPersistence(
2519
+ mapOf(
2520
+ "type" to EventType.KEYBOARD_TYPING,
2521
+ "timestamp" to System.currentTimeMillis(),
2522
+ "keyPressCount" to characterCount
2523
+ )
2524
+ )
2525
+ }
2526
+ }
2527
+
2528
+ // ==================== ANRHandler.ANRListener ====================
2529
+
2530
+ override fun onANRDetected(durationMs: Long, threadState: String?) {
2531
+ // CRASH PREVENTION: Wrap in try-catch to never crash host app
2532
+ try {
2533
+ if (!isRecording) return
2534
+
2535
+ Logger.debug("ANR callback: duration=${durationMs}ms")
2536
+
2537
+ // Log ANR as an event for timeline display
2538
+ val eventMap = mutableMapOf<String, Any?>(
2539
+ "type" to "anr",
2540
+ "timestamp" to System.currentTimeMillis(),
2541
+ "durationMs" to durationMs
2542
+ )
2543
+ threadState?.let { eventMap["threadState"] = it }
2544
+ addEventWithPersistence(eventMap)
2545
+
2546
+ // Increment telemetry counter
2547
+ Telemetry.getInstance().recordANR()
2548
+ } catch (e: Exception) {
2549
+ Logger.error("SDK error in onANRDetected (non-fatal)", e)
2550
+ }
2551
+ }
2552
+
2553
+ // ==================== CaptureEngineDelegate ====================
2554
+
2555
+ override fun onSegmentReady(segmentFile: File, startTime: Long, endTime: Long, frameCount: Int) {
2556
+ // CRITICAL FIX: Do NOT delete segment if shutting down - we want to persist it for recovery!
2557
+ if (!isRecording && !isShuttingDown) {
2558
+ // Clean up the segment file if we're not recording (and not shutting down)
2559
+ try {
2560
+ segmentFile.delete()
2561
+ } catch (_: Exception) {}
2562
+ return
2563
+ }
2564
+
2565
+ if (isShuttingDown) {
2566
+ Logger.debug("Segment ready during shutdown - preserving file for recovery: ${segmentFile.name}")
2567
+ // Do not attempt upload now as scope is cancelled. WorkManager/Recovery will handle it.
2568
+ return
2569
+ }
2570
+
2571
+ if (!remoteRecordingEnabled) {
2572
+ try {
2573
+ segmentFile.delete()
2574
+ } catch (_: Exception) {}
2575
+ Logger.debug("Segment upload skipped - recording disabled")
2576
+ return
2577
+ }
2578
+
2579
+ Logger.debug("Segment ready: frames=$frameCount, file=${segmentFile.absolutePath}")
2580
+
2581
+ scope.launch(Dispatchers.IO) {
2582
+ try {
2583
+ val success = uploadManager?.uploadVideoSegment(
2584
+ segmentFile = segmentFile,
2585
+ startTime = startTime,
2586
+ endTime = endTime,
2587
+ frameCount = frameCount
2588
+ ) ?: false
2589
+
2590
+ if (success) {
2591
+ Logger.debug("Segment uploaded successfully")
2592
+ } else {
2593
+ Logger.warning("Segment upload failed")
2594
+ }
2595
+ } catch (e: Exception) {
2596
+ Logger.error("Failed to upload segment", e)
2597
+ }
2598
+ }
2599
+ }
2600
+
2601
+ override fun onCaptureError(error: Exception) {
2602
+ Logger.error("Capture error: ${error.message}", error)
2603
+
2604
+ // Log capture error as an event
2605
+ val eventMap = mutableMapOf<String, Any?>(
2606
+ "type" to "capture_error",
2607
+ "timestamp" to System.currentTimeMillis(),
2608
+ "error" to error.message
2609
+ )
2610
+ addEventWithPersistence(eventMap)
2611
+ }
2612
+
2613
+ override fun onHierarchySnapshotsReady(snapshotsJson: ByteArray, timestamp: Long) {
2614
+ Logger.debug("[HIERARCHY] onHierarchySnapshotsReady: START (size=${snapshotsJson.size} bytes, timestamp=$timestamp)")
2615
+ Logger.debug("[HIERARCHY] isRecording=$isRecording, isShuttingDown=$isShuttingDown, currentSessionId=$currentSessionId")
2616
+
2617
+ if (!isRecording || isShuttingDown) {
2618
+ Logger.warning("[HIERARCHY] onHierarchySnapshotsReady: Skipping - isRecording=$isRecording, isShuttingDown=$isShuttingDown")
2619
+ return
2620
+ }
2621
+
2622
+ if (!remoteRecordingEnabled) {
2623
+ Logger.debug("[HIERARCHY] Skipping upload - recording disabled")
2624
+ return
2625
+ }
2626
+
2627
+ // CRITICAL FIX: Capture current session ID at callback time
2628
+ // This prevents stale session ID issues where UploadManager.sessionId
2629
+ // may still reference a previous session
2630
+ val sid = currentSessionId ?: run {
2631
+ Logger.error("[HIERARCHY] onHierarchySnapshotsReady: No current session ID, cannot upload hierarchy")
2632
+ return
2633
+ }
2634
+
2635
+ Logger.debug("[HIERARCHY] onHierarchySnapshotsReady: Hierarchy snapshots ready for session: $sid")
2636
+ Logger.debug("[HIERARCHY] JSON size: ${snapshotsJson.size} bytes, uploadManager=${uploadManager != null}")
2637
+
2638
+ scope.launch(Dispatchers.IO) {
2639
+ try {
2640
+ Logger.debug("[HIERARCHY] Starting hierarchy upload (sessionId=$sid)")
2641
+ val uploadStartTime = System.currentTimeMillis()
2642
+
2643
+ val success = uploadManager?.uploadHierarchy(
2644
+ hierarchyData = snapshotsJson,
2645
+ timestamp = timestamp,
2646
+ sessionId = sid // Pass session ID explicitly
2647
+ ) ?: false
2648
+
2649
+ val uploadDuration = System.currentTimeMillis() - uploadStartTime
2650
+
2651
+ if (success) {
2652
+ Logger.debug("[HIERARCHY] ✅ Hierarchy snapshots uploaded successfully in ${uploadDuration}ms (sessionId=$sid)")
2653
+ } else {
2654
+ Logger.error("[HIERARCHY] ❌ Hierarchy snapshots upload FAILED after ${uploadDuration}ms (sessionId=$sid)")
2655
+ }
2656
+ } catch (e: Exception) {
2657
+ Logger.error("[HIERARCHY] ❌ Exception during hierarchy upload (sessionId=$sid): ${e.message}", e)
2658
+ }
2659
+ }
2660
+ }
2661
+
2662
+ // ==================== AuthFailureListener ====================
2663
+
2664
+ /**
2665
+ * Called when authentication fails due to security errors (403/404).
2666
+ *
2667
+ * - 403 (security): Stop immediately and permanently (package name mismatch)
2668
+ * - 404 (not found): Retry with exponential backoff (could be temporary)
2669
+ */
2670
+ override fun onAuthenticationFailure(errorCode: Int, errorMessage: String, domain: String) {
2671
+ Logger.error("Authentication failure: code=$errorCode, message=$errorMessage, domain=$domain")
2672
+
2673
+ when (errorCode) {
2674
+ 403 -> {
2675
+ // SECURITY: Package name mismatch or access forbidden - PERMANENT failure
2676
+ Logger.error("SECURITY: Access forbidden - stopping recording permanently")
2677
+ authPermanentlyFailed = true
2678
+ handleAuthenticationFailurePermanent(errorCode, errorMessage, domain)
2679
+ }
2680
+ else -> {
2681
+ // 404 and other errors - retry with exponential backoff
2682
+ // Recording continues locally, events queued for later upload
2683
+ scheduleAuthRetry(errorCode, errorMessage, domain)
2684
+ }
2685
+ }
2686
+ }
2687
+
2688
+ /**
2689
+ * Schedule auth retry with exponential backoff.
2690
+ * Recording continues locally while retrying.
2691
+ */
2692
+ private fun scheduleAuthRetry(errorCode: Int, errorMessage: String, domain: String) {
2693
+ if (authPermanentlyFailed || isShuttingDown) {
2694
+ return
2695
+ }
2696
+
2697
+ authRetryCount++
2698
+
2699
+ // Check max retries
2700
+ if (authRetryCount > MAX_AUTH_RETRIES) {
2701
+ Logger.error("Auth failed after $MAX_AUTH_RETRIES retries. Recording continues locally.")
2702
+
2703
+ // Emit warning (not error) - recording continues
2704
+ emitAuthWarningEvent(errorCode, "Auth failed after max retries. Recording locally.", authRetryCount)
2705
+
2706
+ // Schedule long background retry (5 minutes)
2707
+ scheduleBackgroundAuthRetry(AUTH_BACKGROUND_RETRY_DELAY_MS)
2708
+ return
2709
+ }
2710
+
2711
+ // Calculate exponential backoff: 2s, 4s, 8s, 16s, 32s, capped at 60s
2712
+ val delay = minOf(
2713
+ AUTH_RETRY_BASE_DELAY_MS * (1L shl (authRetryCount - 1)),
2714
+ AUTH_RETRY_MAX_DELAY_MS
2715
+ )
2716
+
2717
+ Logger.info("Auth failed (attempt $authRetryCount/$MAX_AUTH_RETRIES), retrying in ${delay}ms. " +
2718
+ "Recording continues locally. Error: $errorMessage")
2719
+
2720
+ // After 2 failed attempts, clear cached auth data and re-register fresh
2721
+ if (authRetryCount >= 2) {
2722
+ Logger.info("Clearing cached auth data and re-registering fresh...")
2723
+ deviceAuthManager?.clearCredentials()
2724
+ }
2725
+
2726
+ scheduleBackgroundAuthRetry(delay)
2727
+ }
2728
+
2729
+ /**
2730
+ * Schedule a background auth retry after specified delay.
2731
+ */
2732
+ private fun scheduleBackgroundAuthRetry(delayMs: Long) {
2733
+ // Cancel any existing retry job
2734
+ authRetryJob?.cancel()
2735
+
2736
+ authRetryJob = scope.launch {
2737
+ delay(delayMs)
2738
+ if (!authPermanentlyFailed && !isShuttingDown) {
2739
+ Logger.info("Retrying auth (attempt ${authRetryCount + 1})...")
2740
+ performAuthRetry()
2741
+ }
2742
+ }
2743
+ }
2744
+
2745
+ /**
2746
+ * Perform the auth retry - re-initialize device auth.
2747
+ */
2748
+ private fun performAuthRetry() {
2749
+ if (savedApiUrl.isNotEmpty() && savedPublicKey.isNotEmpty()) {
2750
+ deviceAuthManager?.registerDevice(
2751
+ projectKey = savedPublicKey,
2752
+ bundleId = reactContext.packageName,
2753
+ platform = "android",
2754
+ sdkVersion = Constants.SDK_VERSION,
2755
+ apiUrl = savedApiUrl
2756
+ ) { success, credentialId, error ->
2757
+ if (success) {
2758
+ Logger.debug("Auth retry successful: device registered: $credentialId")
2759
+ resetAuthRetryState()
2760
+ // Get upload token after successful registration
2761
+ deviceAuthManager?.getUploadToken { tokenSuccess, token, expiresIn, tokenError ->
2762
+ if (tokenSuccess) {
2763
+ Logger.debug("Upload token obtained after auth retry")
2764
+ } else {
2765
+ Logger.warning("Failed to get upload token after auth retry: $tokenError")
2766
+ }
2767
+ }
2768
+ } else {
2769
+ Logger.warning("Auth retry failed: $error")
2770
+ }
2771
+ }
2772
+ }
2773
+ }
2774
+
2775
+ /**
2776
+ * Reset auth retry state (called when auth succeeds).
2777
+ */
2778
+ private fun resetAuthRetryState() {
2779
+ authRetryCount = 0
2780
+ authPermanentlyFailed = false
2781
+ authRetryJob?.cancel()
2782
+ authRetryJob = null
2783
+ }
2784
+
2785
+ /**
2786
+ * Handle PERMANENT authentication failure (403 security errors only).
2787
+ * Stops recording, clears credentials, and emits error event to JS.
2788
+ */
2789
+ private fun handleAuthenticationFailurePermanent(errorCode: Int, errorMessage: String, domain: String) {
2790
+ // Must run on main thread for React Native event emission
2791
+ Handler(Looper.getMainLooper()).post {
2792
+ try {
2793
+ // Stop recording immediately
2794
+ if (isRecording) {
2795
+ Logger.warning("Stopping recording due to security authentication failure")
2796
+
2797
+ // Stop capture engine
2798
+ captureEngine?.stopSession()
2799
+
2800
+ // Disable touch tracking
2801
+ touchInterceptor?.disableGlobalTracking()
2802
+
2803
+ // Stop keyboard and text input tracking
2804
+ keyboardTracker?.stopTracking()
2805
+ textInputTracker?.stopTracking()
2806
+
2807
+ // Stop timers
2808
+ stopBatchUploadTimer()
2809
+ stopDurationLimitTimer()
2810
+
2811
+ // Clear session state
2812
+ isRecording = false
2813
+ currentSessionId = null
2814
+ userId = null
2815
+ sessionEvents.clear()
2816
+ }
2817
+
2818
+ // Clear stored credentials
2819
+ deviceAuthManager?.clearCredentials()
2820
+
2821
+ // Emit error event to JavaScript layer
2822
+ emitAuthErrorEvent(errorCode, errorMessage, domain)
2823
+
2824
+ } catch (e: Exception) {
2825
+ Logger.error("Error handling authentication failure", e)
2826
+ }
2827
+ }
2828
+ }
2829
+
2830
+ /**
2831
+ * Emit auth warning event (for retryable failures).
2832
+ */
2833
+ private fun emitAuthWarningEvent(errorCode: Int, errorMessage: String, retryCount: Int) {
2834
+ try {
2835
+ val params = Arguments.createMap().apply {
2836
+ putInt("code", errorCode)
2837
+ putString("message", errorMessage)
2838
+ putInt("retryCount", retryCount)
2839
+ }
2840
+
2841
+ reactContext
2842
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
2843
+ ?.emit("rejourneyAuthWarning", params)
2844
+
2845
+ Logger.debug("Emitted rejourneyAuthWarning event to JS: code=$errorCode, retryCount=$retryCount")
2846
+ } catch (e: Exception) {
2847
+ Logger.error("Failed to emit auth warning event", e)
2848
+ }
2849
+ }
2850
+
2851
+ /**
2852
+ * Emit rejourneyAuthError event to JavaScript layer.
2853
+ */
2854
+ private fun emitAuthErrorEvent(errorCode: Int, errorMessage: String, domain: String) {
2855
+ try {
2856
+ val params = Arguments.createMap().apply {
2857
+ putInt("code", errorCode)
2858
+ putString("message", errorMessage)
2859
+ putString("domain", domain)
2860
+ }
2861
+
2862
+ reactContext
2863
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
2864
+ ?.emit("rejourneyAuthError", params)
2865
+
2866
+ Logger.debug("Emitted rejourneyAuthError event to JS: code=$errorCode")
2867
+ } catch (e: Exception) {
2868
+ Logger.error("Failed to emit auth error event", e)
2869
+ }
2870
+ }
2871
+
2872
+ /**
2873
+ * Check if the app was killed in the previous session using ApplicationExitInfo (Android 11+).
2874
+ * This is a fallback mechanism when onTaskRemoved() doesn't fire.
2875
+ */
2876
+ private fun checkPreviousAppKill() {
2877
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
2878
+ // ApplicationExitInfo is only available on Android 11+ (API 30)
2879
+ return
2880
+ }
2881
+
2882
+ try {
2883
+ val activityManager = reactContext.getSystemService(Context.ACTIVITY_SERVICE) as? android.app.ActivityManager
2884
+ if (activityManager == null) {
2885
+ Logger.debug("ActivityManager not available for exit info check")
2886
+ return
2887
+ }
2888
+
2889
+ // Get historical exit reasons for this process
2890
+ val exitReasons = activityManager.getHistoricalProcessExitReasons(null, 0, 1)
2891
+
2892
+ if (exitReasons.isNotEmpty()) {
2893
+ val exitInfo = exitReasons[0]
2894
+ val reason = exitInfo.reason
2895
+ val timestamp = exitInfo.timestamp
2896
+
2897
+ Logger.debug("Previous app exit: reason=$reason, timestamp=$timestamp")
2898
+
2899
+ // Check if app was killed by user (swipe away, force stop, etc.)
2900
+ // REASON_USER_REQUESTED includes swipe-away from recent apps
2901
+ if (reason == android.app.ApplicationExitInfo.REASON_USER_REQUESTED) {
2902
+ Logger.debug("App was killed by user (likely swipe-away) - checking for unclosed session")
2903
+ // This will be handled by checkForUnclosedSessions()
2904
+ }
2905
+ }
2906
+ } catch (e: Exception) {
2907
+ Logger.warning("Failed to check previous app kill: ${e.message}")
2908
+ }
2909
+ }
2910
+
2911
+ /**
2912
+ * Check for unclosed sessions from previous app launch.
2913
+ * If a session was active but never properly ended, end it now.
2914
+ */
2915
+ private fun checkForUnclosedSessions() {
2916
+ try {
2917
+ val prefs = reactContext.getSharedPreferences("rejourney", Context.MODE_PRIVATE)
2918
+ val lastSessionId = prefs.getString("rj_current_session_id", null)
2919
+ val lastSessionStartTime = prefs.getLong("rj_session_start_time", 0)
2920
+
2921
+ if (lastSessionId != null && lastSessionStartTime > 0) {
2922
+ // Check if session was never closed (no end timestamp stored)
2923
+ val sessionEndTime = prefs.getLong("rj_session_end_time_$lastSessionId", 0)
2924
+
2925
+ if (sessionEndTime == 0L) {
2926
+ Logger.debug("Found unclosed session: $lastSessionId (started at $lastSessionStartTime)")
2927
+
2928
+ // Session was never properly closed - likely app was killed
2929
+ // End the session asynchronously using the upload manager
2930
+ backgroundScope.launch {
2931
+ try {
2932
+ // Reconstruct upload manager state if needed
2933
+ uploadManager?.let { um ->
2934
+ // Set the session ID temporarily to allow endSession to work
2935
+ val originalSessionId = um.sessionId
2936
+ um.sessionId = lastSessionId
2937
+
2938
+ // Try to end the session with the last known timestamp
2939
+ // Use a timestamp slightly before now to account for the gap
2940
+ val estimatedEndTime = System.currentTimeMillis() - 1000 // 1 second before now
2941
+
2942
+ Logger.debug("Ending unclosed session: $lastSessionId at $estimatedEndTime")
2943
+
2944
+ // Use the upload manager's endSession with override timestamp
2945
+ val success = um.endSession(endedAtOverride = estimatedEndTime)
2946
+
2947
+ // Restore original session ID
2948
+ um.sessionId = originalSessionId
2949
+
2950
+ if (success) {
2951
+ Logger.debug("Successfully ended unclosed session: $lastSessionId")
2952
+ // Clear the session markers
2953
+ um.clearSessionRecovery(lastSessionId)
2954
+
2955
+ // Update prefs to mark session as closed
2956
+ prefs.edit()
2957
+ .putLong("rj_session_end_time_$lastSessionId", estimatedEndTime)
2958
+ .remove("rj_current_session_id")
2959
+ .remove("rj_session_start_time")
2960
+ .apply()
2961
+ } else {
2962
+ Logger.warning("Failed to end unclosed session: $lastSessionId")
2963
+ }
2964
+ }
2965
+ } catch (e: Exception) {
2966
+ Logger.error("Error ending unclosed session: ${e.message}", e)
2967
+ }
2968
+ }
2969
+ } else {
2970
+ // Session was properly closed, clear old markers
2971
+ prefs.edit()
2972
+ .remove("rj_current_session_id")
2973
+ .remove("rj_session_start_time")
2974
+ .apply()
2975
+ }
2976
+ }
2977
+ } catch (e: Exception) {
2978
+ Logger.error("Failed to check for unclosed sessions: ${e.message}", e)
2979
+ }
2980
+ }
2981
+ }