@rejourneyco/react-native 1.0.1 → 1.0.3

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 (65) hide show
  1. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +72 -391
  2. package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +11 -113
  3. package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +1 -15
  4. package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +1 -61
  5. package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +3 -1
  6. package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +1 -22
  7. package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +3 -26
  8. package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +0 -2
  9. package/android/src/main/java/com/rejourney/network/UploadManager.kt +7 -93
  10. package/android/src/main/java/com/rejourney/network/UploadWorker.kt +5 -41
  11. package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +2 -58
  12. package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +4 -4
  13. package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +36 -7
  14. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +7 -0
  15. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +9 -0
  16. package/ios/Capture/RJCaptureEngine.m +3 -34
  17. package/ios/Capture/RJVideoEncoder.m +0 -26
  18. package/ios/Capture/RJViewHierarchyScanner.m +68 -51
  19. package/ios/Core/RJLifecycleManager.m +0 -14
  20. package/ios/Core/Rejourney.mm +53 -129
  21. package/ios/Network/RJDeviceAuthManager.m +0 -2
  22. package/ios/Network/RJUploadManager.h +8 -0
  23. package/ios/Network/RJUploadManager.m +45 -0
  24. package/ios/Privacy/RJPrivacyMask.m +5 -31
  25. package/ios/Rejourney.h +0 -14
  26. package/ios/Touch/RJTouchInterceptor.m +21 -15
  27. package/ios/Utils/RJEventBuffer.m +57 -69
  28. package/ios/Utils/RJPerfTiming.m +0 -5
  29. package/ios/Utils/RJWindowUtils.m +87 -87
  30. package/lib/commonjs/components/Mask.js +1 -6
  31. package/lib/commonjs/index.js +46 -117
  32. package/lib/commonjs/sdk/autoTracking.js +39 -313
  33. package/lib/commonjs/sdk/constants.js +2 -13
  34. package/lib/commonjs/sdk/errorTracking.js +1 -29
  35. package/lib/commonjs/sdk/metricsTracking.js +3 -24
  36. package/lib/commonjs/sdk/navigation.js +3 -42
  37. package/lib/commonjs/sdk/networkInterceptor.js +7 -60
  38. package/lib/commonjs/sdk/utils.js +73 -19
  39. package/lib/module/components/Mask.js +1 -6
  40. package/lib/module/index.js +45 -121
  41. package/lib/module/sdk/autoTracking.js +39 -314
  42. package/lib/module/sdk/constants.js +2 -13
  43. package/lib/module/sdk/errorTracking.js +1 -29
  44. package/lib/module/sdk/index.js +0 -2
  45. package/lib/module/sdk/metricsTracking.js +3 -24
  46. package/lib/module/sdk/navigation.js +3 -42
  47. package/lib/module/sdk/networkInterceptor.js +7 -60
  48. package/lib/module/sdk/utils.js +73 -19
  49. package/lib/typescript/NativeRejourney.d.ts +1 -0
  50. package/lib/typescript/sdk/autoTracking.d.ts +4 -4
  51. package/lib/typescript/sdk/utils.d.ts +31 -1
  52. package/lib/typescript/types/index.d.ts +0 -1
  53. package/package.json +17 -11
  54. package/src/NativeRejourney.ts +2 -0
  55. package/src/components/Mask.tsx +0 -3
  56. package/src/index.ts +43 -92
  57. package/src/sdk/autoTracking.ts +51 -284
  58. package/src/sdk/constants.ts +13 -13
  59. package/src/sdk/errorTracking.ts +1 -17
  60. package/src/sdk/index.ts +0 -2
  61. package/src/sdk/metricsTracking.ts +5 -33
  62. package/src/sdk/navigation.ts +8 -29
  63. package/src/sdk/networkInterceptor.ts +9 -42
  64. package/src/sdk/utils.ts +76 -19
  65. package/src/types/index.ts +0 -29
@@ -57,7 +57,6 @@ import java.io.File
57
57
  import java.util.*
58
58
  import java.util.concurrent.CopyOnWriteArrayList
59
59
 
60
- // Enum for session end reasons
61
60
  enum class EndReason {
62
61
  SESSION_TIMEOUT,
63
62
  MANUAL_STOP,
@@ -80,25 +79,21 @@ class RejourneyModuleImpl(
80
79
 
81
80
  companion object {
82
81
  const val NAME = "Rejourney"
83
- const val BACKGROUND_RESUME_TIMEOUT_MS = 30_000L // 30 seconds
82
+ const val BACKGROUND_RESUME_TIMEOUT_MS = 30_000L
84
83
 
85
- // Auth retry constants
86
84
  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
85
+ private const val AUTH_RETRY_BASE_DELAY_MS = 2000L
86
+ private const val AUTH_RETRY_MAX_DELAY_MS = 60000L
87
+ private const val AUTH_BACKGROUND_RETRY_DELAY_MS = 300000L
90
88
 
91
- // Store process start time at class load for accurate app startup measurement
92
89
  @JvmStatic
93
90
  private val processStartTimeMs: Long = run {
94
91
  try {
95
92
  if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
96
- // API 24+: Use Process.getStartElapsedRealtime() for accurate measurement
97
93
  val startElapsed = android.os.Process.getStartElapsedRealtime()
98
94
  val nowElapsed = android.os.SystemClock.elapsedRealtime()
99
95
  System.currentTimeMillis() - (nowElapsed - startElapsed)
100
96
  } else {
101
- // Fallback for older devices: use current time (less accurate)
102
97
  System.currentTimeMillis()
103
98
  }
104
99
  } catch (e: Exception) {
@@ -107,7 +102,6 @@ class RejourneyModuleImpl(
107
102
  }
108
103
  }
109
104
 
110
- // Core components
111
105
  private var captureEngine: CaptureEngine? = null
112
106
  private var uploadManager: UploadManager? = null
113
107
  private var touchInterceptor: TouchInterceptor? = null
@@ -116,7 +110,6 @@ class RejourneyModuleImpl(
116
110
  private var keyboardTracker: KeyboardTracker? = null
117
111
  private var textInputTracker: TextInputTracker? = null
118
112
 
119
- // Session state
120
113
  private var currentSessionId: String? = null
121
114
  private var userId: String? = null
122
115
  @Volatile private var isRecording: Boolean = false
@@ -134,59 +127,42 @@ class RejourneyModuleImpl(
134
127
  private var maxRecordingMinutes: Int = 10
135
128
  @Volatile private var sessionEndSent: Boolean = false
136
129
 
137
- // Keyboard state
138
130
  private var keyPressCount: Int = 0
139
131
  private var isKeyboardVisible: Boolean = false
140
132
  private var lastKeyboardHeight: Int = 0
141
133
 
142
- // Session state saved on background - used to restore on foreground if within timeout
143
134
  private var savedApiUrl: String = ""
144
135
  private var savedPublicKey: String = ""
145
136
  private var savedDeviceHash: String = ""
146
137
 
147
- // Events buffer
148
138
  private val sessionEvents = CopyOnWriteArrayList<Map<String, Any?>>()
149
139
 
150
- // Throttle immediate upload kicks (ms)
151
140
  @Volatile private var lastImmediateUploadKickMs: Long = 0
152
141
 
153
- // Write-first event buffer for crash-safe persistence
154
142
  private var eventBuffer: EventBuffer? = null
155
143
 
156
- // Coroutine scope for async operations
157
144
  private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
158
145
 
159
- // Dedicated scope for background flush - survives independently of main scope
160
- // This prevents cancellation when app goes to background
161
146
  private val backgroundScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
162
147
 
163
- // Timer jobs
164
148
  private var batchUploadJob: Job? = null
165
149
  private var durationLimitJob: Job? = null
166
150
 
167
- // Main thread handler for posting delayed tasks
168
151
  private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
169
152
 
170
- // Debounced background detection (prevents transient pauses from ending sessions)
171
153
  private var scheduledBackgroundRunnable: Runnable? = null
172
154
  private var backgroundScheduled: Boolean = false
173
155
 
174
- // Safety flag
175
156
  @Volatile private var isShuttingDown = false
176
157
 
177
- // Auth resilience - retry mechanism
178
158
  private var authRetryCount = 0
179
159
  private var authPermanentlyFailed = false
180
160
  private var authRetryJob: Job? = null
181
161
 
182
162
  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
163
  Logger.debug("RejourneyModuleImpl constructor completed")
187
164
  }
188
165
 
189
- // Lazy initialization flag
190
166
  @Volatile
191
167
  private var isInitialized = false
192
168
  private val initLock = Any()
@@ -207,7 +183,6 @@ class RejourneyModuleImpl(
207
183
  registerActivityLifecycleCallbacks()
208
184
  registerProcessLifecycleObserver()
209
185
 
210
- // Start crash handler, ANR handler, and network monitor with error handling
211
186
  try {
212
187
  CrashHandler.getInstance(reactContext).startMonitoring()
213
188
  } catch (e: Exception) {
@@ -229,49 +204,38 @@ class RejourneyModuleImpl(
229
204
  Logger.error("Failed to start network monitor (non-critical)", e)
230
205
  }
231
206
 
232
- // Schedule recovery of any pending uploads from previous sessions
233
- // This handles cases where the app was killed before uploads completed
234
207
  try {
235
208
  UploadWorker.scheduleRecoveryUpload(reactContext)
236
209
  } catch (e: Exception) {
237
210
  Logger.error("Failed to schedule recovery upload (non-critical)", e)
238
211
  }
239
212
 
240
- // Check if app was killed in previous session (Android 11+)
241
213
  try {
242
214
  checkPreviousAppKill()
243
215
  } catch (e: Exception) {
244
216
  Logger.error("Failed to check previous app kill (non-critical)", e)
245
217
  }
246
218
 
247
- // Check for unclosed sessions from previous launch
248
219
  try {
249
220
  checkForUnclosedSessions()
250
221
  } catch (e: Exception) {
251
222
  Logger.error("Failed to check for unclosed sessions (non-critical)", e)
252
223
  }
253
224
 
254
- // Log OEM information for debugging
255
225
  val oem = OEMDetector.getOEM()
256
226
  Logger.debug("Device OEM: $oem")
257
227
  Logger.debug("OEM Recommendations: ${OEMDetector.getRecommendations()}")
258
228
  Logger.debug("onTaskRemoved() reliable: ${OEMDetector.isTaskRemovedReliable()}")
259
229
 
260
- // Set up SessionLifecycleService listener to detect app termination
261
230
  try {
262
231
  SessionLifecycleService.taskRemovedListener = object : TaskRemovedListener {
263
232
  override fun onTaskRemoved() {
264
233
  Logger.debug("[Rejourney] App terminated via swipe-away - SYNCHRONOUS session end (OEM: $oem)")
265
234
 
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
235
  if (isRecording && !sessionEndSent) {
270
236
  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
237
  runBlocking {
274
- withTimeout(5000L) { // 5 second timeout
238
+ withTimeout(5000L) {
275
239
  Logger.debug("[Rejourney] Starting synchronous session end...")
276
240
  endSessionSynchronous()
277
241
  Logger.debug("[Rejourney] Synchronous session end completed")
@@ -291,13 +255,11 @@ class RejourneyModuleImpl(
291
255
  Logger.error("Failed to set up task removed listener (non-critical)", e)
292
256
  }
293
257
 
294
- // Use lifecycle log - only shown in debug builds
295
258
  Logger.logInitSuccess(Constants.SDK_VERSION)
296
259
 
297
260
  isInitialized = true
298
261
  } catch (e: Exception) {
299
262
  Logger.logInitFailure("${e.javaClass.simpleName}: ${e.message}")
300
- // Mark as initialized anyway to prevent retry loops
301
263
  isInitialized = true
302
264
  }
303
265
  }
@@ -313,8 +275,6 @@ class RejourneyModuleImpl(
313
275
 
314
276
  Logger.debug("[Rejourney] addEventWithPersistence: type=$eventType, sessionId=$sessionId, inMemoryCount=${sessionEvents.size + 1}")
315
277
 
316
- // CRITICAL: Write to disk immediately for crash safety
317
- // This ensures events are never lost even if app is force-killed
318
278
  val bufferSuccess = eventBuffer?.appendEvent(event) ?: false
319
279
  if (!bufferSuccess) {
320
280
  Logger.warning("[Rejourney] addEventWithPersistence: Failed to append event to buffer: type=$eventType")
@@ -322,7 +282,6 @@ class RejourneyModuleImpl(
322
282
  Logger.debug("[Rejourney] addEventWithPersistence: Event appended to buffer: type=$eventType")
323
283
  }
324
284
 
325
- // Also add to in-memory buffer for batched upload
326
285
  sessionEvents.add(event)
327
286
  Logger.debug("[Rejourney] addEventWithPersistence: Event added to in-memory list: type=$eventType, totalInMemory=${sessionEvents.size}")
328
287
  }
@@ -332,14 +291,12 @@ class RejourneyModuleImpl(
332
291
  * This is more reliable than Activity lifecycle callbacks.
333
292
  */
334
293
  private fun registerProcessLifecycleObserver() {
335
- // Must run on main thread
336
294
  Handler(Looper.getMainLooper()).post {
337
295
  try {
338
296
  ProcessLifecycleOwner.get().lifecycle.addObserver(this)
339
297
  Logger.debug("ProcessLifecycleOwner observer registered")
340
298
  } catch (e: Exception) {
341
299
  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
300
  }
344
301
  }
345
302
  }
@@ -354,7 +311,6 @@ class RejourneyModuleImpl(
354
311
 
355
312
  private fun setupComponents() {
356
313
  try {
357
- // Initialize capture engine with video segment mode
358
314
  captureEngine = CaptureEngine(reactContext).apply {
359
315
  captureScale = Constants.DEFAULT_CAPTURE_SCALE
360
316
  minFrameInterval = Constants.DEFAULT_MIN_FRAME_INTERVAL
@@ -371,7 +327,6 @@ class RejourneyModuleImpl(
371
327
  }
372
328
 
373
329
  try {
374
- // Initialize upload manager
375
330
  uploadManager = UploadManager(reactContext, "https://api.rejourney.co")
376
331
  Logger.debug("UploadManager initialized")
377
332
  } catch (e: Exception) {
@@ -380,7 +335,6 @@ class RejourneyModuleImpl(
380
335
  }
381
336
 
382
337
  try {
383
- // Initialize touch interceptor
384
338
  touchInterceptor = TouchInterceptor.getInstance(reactContext).apply {
385
339
  delegate = this@RejourneyModuleImpl
386
340
  }
@@ -391,7 +345,6 @@ class RejourneyModuleImpl(
391
345
  }
392
346
 
393
347
  try {
394
- // Initialize device auth manager
395
348
  deviceAuthManager = DeviceAuthManager.getInstance(reactContext).apply {
396
349
  authFailureListener = this@RejourneyModuleImpl
397
350
  }
@@ -402,7 +355,6 @@ class RejourneyModuleImpl(
402
355
  }
403
356
 
404
357
  try {
405
- // Initialize network monitor
406
358
  networkMonitor = NetworkMonitor.getInstance(reactContext).apply {
407
359
  listener = this@RejourneyModuleImpl
408
360
  }
@@ -413,7 +365,6 @@ class RejourneyModuleImpl(
413
365
  }
414
366
 
415
367
  try {
416
- // Initialize keyboard tracker (for keyboard show/hide events)
417
368
  keyboardTracker = KeyboardTracker.getInstance(reactContext).apply {
418
369
  listener = this@RejourneyModuleImpl
419
370
  }
@@ -424,7 +375,6 @@ class RejourneyModuleImpl(
424
375
  }
425
376
 
426
377
  try {
427
- // Initialize text input tracker (for key press counting)
428
378
  textInputTracker = TextInputTracker.getInstance(reactContext).apply {
429
379
  listener = this@RejourneyModuleImpl
430
380
  }
@@ -462,27 +412,57 @@ class RejourneyModuleImpl(
462
412
  val application = reactContext.applicationContext as? Application
463
413
  application?.unregisterActivityLifecycleCallbacks(this)
464
414
 
465
- // Unregister from ProcessLifecycleOwner
466
415
  Handler(Looper.getMainLooper()).post {
467
416
  try {
468
417
  ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
469
418
  } catch (e: Exception) {
470
- // Ignore
471
419
  }
472
420
  }
473
421
  }
474
422
 
475
- // ==================== React Native Methods ====================
423
+ fun getDeviceInfo(promise: Promise) {
424
+ try {
425
+ val map = Arguments.createMap()
426
+ map.putString("model", android.os.Build.MODEL)
427
+ map.putString("brand", android.os.Build.MANUFACTURER)
428
+ map.putString("systemName", "Android")
429
+ map.putString("systemVersion", android.os.Build.VERSION.RELEASE)
430
+ map.putString("bundleId", reactContext.packageName)
431
+
432
+ try {
433
+ val pInfo = reactContext.packageManager.getPackageInfo(reactContext.packageName, 0)
434
+ map.putString("appVersion", pInfo.versionName)
435
+ if (android.os.Build.VERSION.SDK_INT >= 28) {
436
+ map.putString("buildNumber", pInfo.longVersionCode.toString())
437
+ } else {
438
+ @Suppress("DEPRECATION")
439
+ map.putString("buildNumber", pInfo.versionCode.toString())
440
+ }
441
+ } catch (e: Exception) {
442
+ map.putString("appVersion", "unknown")
443
+ }
444
+
445
+ map.putBoolean("isTablet", isTablet())
446
+ promise.resolve(map)
447
+ } catch (e: Exception) {
448
+ promise.reject("DEVICE_INFO_ERROR", e)
449
+ }
450
+ }
451
+
452
+ private fun isTablet(): Boolean {
453
+ val configuration = reactContext.resources.configuration
454
+ return (configuration.screenLayout and android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK) >=
455
+ android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
456
+ }
476
457
 
477
458
  fun startSession(userId: String, apiUrl: String, publicKey: String, promise: Promise) {
478
- ensureInitialized() // Lazy init on first call
459
+ ensureInitialized()
479
460
 
480
461
  if (isShuttingDown) {
481
462
  promise.resolve(createResultMap(false, "", "Module is shutting down"))
482
463
  return
483
464
  }
484
465
 
485
- // Optimistically allow start; remote config will shut down if disabled.
486
466
  remoteRejourneyEnabled = true
487
467
 
488
468
  scope.launch {
@@ -496,14 +476,12 @@ class RejourneyModuleImpl(
496
476
  val safeApiUrl = apiUrl.ifEmpty { "https://api.rejourney.co" }
497
477
  val safePublicKey = publicKey.ifEmpty { "" }
498
478
 
499
- // Generate device hash
500
479
  val androidId = Settings.Secure.getString(
501
480
  reactContext.contentResolver,
502
481
  Settings.Secure.ANDROID_ID
503
482
  ) ?: "unknown"
504
483
  val deviceHash = generateSHA256Hash(androidId)
505
484
 
506
- // Setup session
507
485
  this@RejourneyModuleImpl.userId = safeUserId
508
486
  currentSessionId = WindowUtils.generateSessionId()
509
487
  sessionStartTime = System.currentTimeMillis()
@@ -511,36 +489,30 @@ class RejourneyModuleImpl(
511
489
  sessionEndSent = false
512
490
  sessionEvents.clear()
513
491
 
514
- // Reset remote recording flag for this session until config says otherwise
515
492
  remoteRecordingEnabled = true
516
493
  recordingEnabledByConfig = true
517
494
  projectSampleRate = 100
518
495
  hasProjectConfig = false
519
496
  resetSamplingDecision()
520
497
 
521
- // Save session ID for crash handler
522
498
  reactContext.getSharedPreferences("rejourney", 0)
523
499
  .edit()
524
500
  .putString("rj_current_session_id", currentSessionId)
525
501
  .apply()
526
502
 
527
- // Configure upload manager
528
503
  uploadManager?.apply {
529
504
  this.apiUrl = safeApiUrl
530
505
  this.publicKey = safePublicKey
531
506
  this.deviceHash = deviceHash
532
- // NUCLEAR FIX: Use setActiveSessionId() to protect from recovery corruption
533
507
  setActiveSessionId(currentSessionId!!)
534
508
  this.userId = safeUserId
535
509
  this.sessionStartTime = this@RejourneyModuleImpl.sessionStartTime
536
510
  resetForNewSession()
537
511
  }
538
512
 
539
- // Mark session active for crash recovery (disk-backed)
540
513
  currentSessionId?.let { sid ->
541
514
  uploadManager?.markSessionActive(sid, sessionStartTime)
542
515
 
543
- // Also save to SharedPreferences for unclosed session detection
544
516
  reactContext.getSharedPreferences("rejourney", 0)
545
517
  .edit()
546
518
  .putString("rj_current_session_id", sid)
@@ -548,33 +520,26 @@ class RejourneyModuleImpl(
548
520
  .apply()
549
521
  }
550
522
 
551
- // Initialize write-first event buffer for crash-safe persistence
552
523
  val pendingDir = java.io.File(reactContext.cacheDir, "rj_pending")
553
524
  currentSessionId?.let { sid ->
554
525
  eventBuffer = EventBuffer(reactContext, sid, pendingDir)
555
526
  }
556
527
 
557
- // Save config for auto-resume on quick background return
558
528
  savedApiUrl = safeApiUrl
559
529
  savedPublicKey = safePublicKey
560
530
  savedDeviceHash = deviceHash
561
531
 
562
- // Start capture engine only if recording is enabled remotely
563
532
  if (remoteRecordingEnabled) {
564
533
  captureEngine?.startSession(currentSessionId!!)
565
534
  }
566
535
 
567
- // Enable touch tracking
568
536
  touchInterceptor?.enableGlobalTracking()
569
537
 
570
- // Start keyboard and text input tracking
571
538
  keyboardTracker?.startTracking()
572
539
  textInputTracker?.startTracking()
573
540
 
574
- // Mark as recording
575
541
  isRecording = true
576
542
 
577
- // Start SessionLifecycleService to detect app termination
578
543
  try {
579
544
  val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
580
545
  reactContext.startService(serviceIntent)
@@ -583,15 +548,12 @@ class RejourneyModuleImpl(
583
548
  Logger.warning("Failed to start SessionLifecycleService: ${e.message}")
584
549
  }
585
550
 
586
- // Start batch uploads
587
551
  startBatchUploadTimer()
588
552
  startDurationLimitTimer()
589
553
 
590
- // Emit app_startup event with startup duration
591
- // This is the time from process start to session start
592
554
  val nowMs = System.currentTimeMillis()
593
555
  val startupDurationMs = nowMs - processStartTimeMs
594
- if (startupDurationMs > 0 && startupDurationMs < 60000) { // Sanity check: < 60s
556
+ if (startupDurationMs > 0 && startupDurationMs < 60000) {
595
557
  val startupEvent = mapOf(
596
558
  "type" to "app_startup",
597
559
  "timestamp" to nowMs,
@@ -602,13 +564,10 @@ class RejourneyModuleImpl(
602
564
  Logger.debug("Recorded app startup time: ${startupDurationMs}ms")
603
565
  }
604
566
 
605
- // Fetch project config
606
567
  fetchProjectConfig(safePublicKey, safeApiUrl)
607
568
 
608
- // Register device
609
569
  registerDevice(safePublicKey, safeApiUrl)
610
570
 
611
- // Use lifecycle log for session start - only shown in debug builds
612
571
  Logger.logSessionStart(currentSessionId ?: "")
613
572
 
614
573
  promise.resolve(createResultMap(true, currentSessionId ?: ""))
@@ -635,22 +594,17 @@ class RejourneyModuleImpl(
635
594
 
636
595
  val sessionId = currentSessionId ?: ""
637
596
 
638
- // Stop timers
639
597
  stopBatchUploadTimer()
640
598
  stopDurationLimitTimer()
641
599
 
642
- // Force final capture
643
600
  if (remoteRecordingEnabled) {
644
601
  captureEngine?.forceCaptureWithReason("session_end")
645
602
  }
646
603
 
647
- // Stop capture engine (triggers final segment upload via delegate)
648
604
  captureEngine?.stopSession()
649
605
 
650
- // Disable touch tracking
651
606
  touchInterceptor?.disableGlobalTracking()
652
607
 
653
- // Build metrics for promotion evaluation
654
608
  var crashCount = 0
655
609
  var anrCount = 0
656
610
  var errorCount = 0
@@ -670,7 +624,6 @@ class RejourneyModuleImpl(
670
624
  "durationSeconds" to durationSeconds
671
625
  )
672
626
 
673
- // Evaluate replay promotion
674
627
  val promotionResult = uploadManager?.evaluateReplayPromotion(metrics)
675
628
  val isPromoted = promotionResult?.first ?: false
676
629
  val reason = promotionResult?.second ?: "unknown"
@@ -681,11 +634,9 @@ class RejourneyModuleImpl(
681
634
  Logger.debug("Session not promoted (reason: $reason)")
682
635
  }
683
636
 
684
- // Upload remaining events (video segments uploaded via delegate callbacks)
685
637
  val uploadSuccess = uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
686
638
 
687
- // Send session end signal if not already sent
688
- var endSessionSuccess = sessionEndSent // Already sent counts as success
639
+ var endSessionSuccess = sessionEndSent
689
640
  if (!sessionEndSent) {
690
641
  sessionEndSent = true
691
642
  endSessionSuccess = uploadManager?.endSession() ?: false
@@ -694,12 +645,10 @@ class RejourneyModuleImpl(
694
645
  }
695
646
  }
696
647
 
697
- // Clear crash recovery markers only if the session is actually closed
698
648
  if (endSessionSuccess) {
699
649
  currentSessionId?.let { sid ->
700
650
  uploadManager?.clearSessionRecovery(sid)
701
651
 
702
- // Mark session as closed in SharedPreferences
703
652
  reactContext.getSharedPreferences("rejourney", 0)
704
653
  .edit()
705
654
  .putLong("rj_session_end_time_$sid", System.currentTimeMillis())
@@ -709,13 +658,11 @@ class RejourneyModuleImpl(
709
658
  }
710
659
  }
711
660
 
712
- // Clear state
713
661
  isRecording = false
714
662
  currentSessionId = null
715
663
  this@RejourneyModuleImpl.userId = null
716
664
  sessionEvents.clear()
717
665
 
718
- // Stop SessionLifecycleService
719
666
  try {
720
667
  val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
721
668
  reactContext.stopService(serviceIntent)
@@ -724,7 +671,6 @@ class RejourneyModuleImpl(
724
671
  Logger.warning("Failed to stop SessionLifecycleService: ${e.message}")
725
672
  }
726
673
 
727
- // Use lifecycle log for session end - only shown in debug builds
728
674
  Logger.logSessionEnd(sessionId)
729
675
 
730
676
  promise.resolve(createStopResultMap(true, sessionId, uploadSuccess && endSessionSuccess, null, null))
@@ -772,7 +718,6 @@ class RejourneyModuleImpl(
772
718
  )
773
719
  addEventWithPersistence(event)
774
720
 
775
- // Notify capture engine with delay for render
776
721
  scope.launch {
777
722
  delay(100)
778
723
  captureEngine?.notifyNavigationToScreen(screenName)
@@ -925,7 +870,6 @@ class RejourneyModuleImpl(
925
870
 
926
871
  fun debugTriggerANR(durationMs: Double) {
927
872
  Logger.debug("Triggering debug ANR for ${durationMs.toLong()}ms...")
928
- // Post to main looper to block the main thread
929
873
  Handler(Looper.getMainLooper()).post {
930
874
  try {
931
875
  Thread.sleep(durationMs.toLong())
@@ -939,8 +883,6 @@ class RejourneyModuleImpl(
939
883
  promise.resolve(currentSessionId)
940
884
  }
941
885
 
942
- // ==================== Privacy / View Masking ====================
943
-
944
886
  fun maskViewByNativeID(nativeID: String, promise: Promise) {
945
887
  if (nativeID.isEmpty()) {
946
888
  promise.resolve(createSuccessMap(false))
@@ -948,8 +890,6 @@ class RejourneyModuleImpl(
948
890
  }
949
891
 
950
892
  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
893
  com.rejourney.privacy.PrivacyMask.addMaskedNativeID(nativeID)
954
894
  Logger.debug("Masked nativeID: $nativeID")
955
895
  promise.resolve(createSuccessMap(true))
@@ -966,7 +906,6 @@ class RejourneyModuleImpl(
966
906
  }
967
907
 
968
908
  try {
969
- // Remove nativeID from the privacy mask set
970
909
  com.rejourney.privacy.PrivacyMask.removeMaskedNativeID(nativeID)
971
910
  Logger.debug("Unmasked nativeID: $nativeID")
972
911
  promise.resolve(createSuccessMap(true))
@@ -981,13 +920,11 @@ class RejourneyModuleImpl(
981
920
  * In React Native, nativeID is typically stored in the view's tag or as a resource ID.
982
921
  */
983
922
  private fun findViewByNativeID(view: android.view.View, nativeID: String): android.view.View? {
984
- // Check if view has matching tag (common RN pattern)
985
923
  val viewTag = view.getTag(com.facebook.react.R.id.view_tag_native_id)
986
924
  if (viewTag is String && viewTag == nativeID) {
987
925
  return view
988
926
  }
989
927
 
990
- // Recurse into ViewGroup children
991
928
  if (view is android.view.ViewGroup) {
992
929
  for (i in 0 until view.childCount) {
993
930
  val child = view.getChildAt(i)
@@ -999,28 +936,22 @@ class RejourneyModuleImpl(
999
936
  return null
1000
937
  }
1001
938
 
1002
- // ==================== User Identity ====================
1003
939
 
1004
940
  fun setUserIdentity(userId: String, promise: Promise) {
1005
941
  try {
1006
942
  val safeUserId = userId.ifEmpty { "anonymous" }
1007
943
 
1008
- // KEY CHANGE: Persist directly to SharedPreferences (Native Storage)
1009
- // This replaces the need for async-storage on the JS side
1010
944
  reactContext.getSharedPreferences("rejourney", 0)
1011
945
  .edit()
1012
946
  .putString("rj_user_identity", safeUserId)
1013
947
  .apply()
1014
948
 
1015
- // Update in-memory state
1016
949
  this.userId = safeUserId
1017
950
 
1018
- // Update upload manager
1019
951
  uploadManager?.userId = safeUserId
1020
952
 
1021
953
  Logger.debug("User identity updated: $safeUserId")
1022
954
 
1023
- // Log event for tracking
1024
955
  if (isRecording) {
1025
956
  val event = mapOf(
1026
957
  "type" to "user_identity_changed",
@@ -1041,7 +972,6 @@ class RejourneyModuleImpl(
1041
972
  promise.resolve(userId)
1042
973
  }
1043
974
 
1044
- // ==================== Helper Methods ====================
1045
975
 
1046
976
  private fun createResultMap(success: Boolean, sessionId: String, error: String? = null): WritableMap {
1047
977
  return Arguments.createMap().apply {
@@ -1103,18 +1033,18 @@ class RejourneyModuleImpl(
1103
1033
  false
1104
1034
  }
1105
1035
 
1106
- val shouldRecord = recordingEnabledByConfig && sessionSampled
1107
- remoteRecordingEnabled = shouldRecord
1036
+ val shouldRecordVideo = recordingEnabledByConfig && sessionSampled
1037
+ remoteRecordingEnabled = shouldRecordVideo
1108
1038
 
1109
- if (!shouldRecord && captureEngine?.isRecording == true) {
1039
+ if (!shouldRecordVideo && captureEngine?.isRecording == true) {
1110
1040
  captureEngine?.stopSession()
1111
1041
  }
1112
1042
 
1113
1043
  if (decidedSample && recordingEnabledByConfig && !sessionSampled) {
1114
- Logger.warning("Session skipped by sample rate (${clampedRate}%)")
1044
+ Logger.info("Session sampled out for video (${clampedRate}%) - entering Data-Only Mode (Events enabled, Video disabled)")
1115
1045
  }
1116
1046
 
1117
- return shouldRecord
1047
+ return shouldRecordVideo
1118
1048
  }
1119
1049
 
1120
1050
  private fun startBatchUploadTimer() {
@@ -1143,7 +1073,6 @@ class RejourneyModuleImpl(
1143
1073
  try {
1144
1074
  performBatchUpload()
1145
1075
  } catch (_: Exception) {
1146
- // Best-effort only
1147
1076
  }
1148
1077
  }
1149
1078
  }
@@ -1179,25 +1108,18 @@ class RejourneyModuleImpl(
1179
1108
  if (!isRecording || isShuttingDown) return
1180
1109
 
1181
1110
  try {
1182
- // Video segments are uploaded via CaptureEngineDelegate callbacks.
1183
- // This timer now only handles event uploads.
1184
-
1185
1111
  val eventsToUpload = sessionEvents.toList()
1186
1112
 
1187
1113
  if (eventsToUpload.isEmpty()) return
1188
1114
 
1189
- // Upload events only (video segments uploaded via delegate)
1190
1115
  val ok = uploadManager?.uploadBatch(eventsToUpload) ?: false
1191
1116
 
1192
1117
  if (ok) {
1193
- // Only clear events after data is safely uploaded
1194
1118
  sessionEvents.clear()
1195
1119
  }
1196
1120
  } catch (e: CancellationException) {
1197
- // Normal cancellation (e.g., app going to background) - not an error
1198
- // WorkManager will handle the upload instead
1199
1121
  Logger.debug("Batch upload cancelled (coroutine cancelled)")
1200
- throw e // Re-throw to propagate cancellation
1122
+ throw e
1201
1123
  } catch (e: Exception) {
1202
1124
  Logger.error("Batch upload failed", e)
1203
1125
  }
@@ -1233,24 +1155,19 @@ class RejourneyModuleImpl(
1233
1155
  val sessionId = currentSessionId ?: ""
1234
1156
  Logger.debug("Ending session due to: $reason")
1235
1157
 
1236
- // Stop timers
1237
1158
  stopBatchUploadTimer()
1238
1159
  stopDurationLimitTimer()
1239
1160
 
1240
- // Force final capture
1241
1161
  if (remoteRecordingEnabled) {
1242
1162
  captureEngine?.forceCaptureWithReason("session_end_${reason.name.lowercase()}")
1243
1163
  }
1244
1164
 
1245
- // Stop capture engine
1246
1165
  captureEngine?.stopSession()
1247
1166
 
1248
- // Disable touch tracking
1249
1167
  touchInterceptor?.disableGlobalTracking()
1250
1168
  keyboardTracker?.stopTracking()
1251
1169
  textInputTracker?.stopTracking()
1252
1170
 
1253
- // Build metrics
1254
1171
  var crashCount = 0
1255
1172
  var anrCount = 0
1256
1173
  var errorCount = 0
@@ -1270,7 +1187,6 @@ class RejourneyModuleImpl(
1270
1187
  "durationSeconds" to durationSeconds
1271
1188
  )
1272
1189
 
1273
- // Evaluate promotion
1274
1190
  val promotionResult = uploadManager?.evaluateReplayPromotion(metrics)
1275
1191
  val isPromoted = promotionResult?.first ?: false
1276
1192
  val promotionReason = promotionResult?.second ?: "unknown"
@@ -1279,22 +1195,18 @@ class RejourneyModuleImpl(
1279
1195
  Logger.debug("Session promoted (reason: $promotionReason)")
1280
1196
  }
1281
1197
 
1282
- // Upload remaining events
1283
1198
  val uploadSuccess = uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
1284
1199
 
1285
- // Send session end signal
1286
1200
  var endSessionSuccess = sessionEndSent
1287
1201
  if (!sessionEndSent) {
1288
1202
  sessionEndSent = true
1289
1203
  endSessionSuccess = uploadManager?.endSession() ?: false
1290
1204
  }
1291
1205
 
1292
- // Clear recovery markers
1293
1206
  if (endSessionSuccess) {
1294
1207
  currentSessionId?.let { sid ->
1295
1208
  uploadManager?.clearSessionRecovery(sid)
1296
1209
 
1297
- // Mark session as closed in SharedPreferences
1298
1210
  reactContext.getSharedPreferences("rejourney", 0)
1299
1211
  .edit()
1300
1212
  .putLong("rj_session_end_time_$sid", System.currentTimeMillis())
@@ -1304,13 +1216,11 @@ class RejourneyModuleImpl(
1304
1216
  }
1305
1217
  }
1306
1218
 
1307
- // Clear state
1308
1219
  isRecording = false
1309
1220
  currentSessionId = null
1310
1221
  userId = null
1311
1222
  sessionEvents.clear()
1312
1223
 
1313
- // Stop SessionLifecycleService
1314
1224
  try {
1315
1225
  val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
1316
1226
  reactContext.stopService(serviceIntent)
@@ -1346,11 +1256,10 @@ class RejourneyModuleImpl(
1346
1256
  Logger.debug("[Rejourney] endSessionSynchronous: Starting for session $sessionId")
1347
1257
 
1348
1258
  try {
1349
- // Stop timers (synchronous)
1350
1259
  stopBatchUploadTimer()
1351
1260
  stopDurationLimitTimer()
1352
1261
 
1353
- // Force final capture
1262
+ /*
1354
1263
  if (remoteRecordingEnabled) {
1355
1264
  try {
1356
1265
  captureEngine?.forceCaptureWithReason("session_end_kill")
@@ -1358,15 +1267,16 @@ class RejourneyModuleImpl(
1358
1267
  Logger.warning("[Rejourney] Final capture failed: ${e.message}")
1359
1268
  }
1360
1269
  }
1270
+ */
1361
1271
 
1362
- // Stop capture engine
1272
+ /*
1363
1273
  try {
1364
1274
  captureEngine?.stopSession()
1365
1275
  } catch (e: Exception) {
1366
1276
  Logger.warning("[Rejourney] Stop capture failed: ${e.message}")
1367
1277
  }
1278
+ */
1368
1279
 
1369
- // Disable tracking
1370
1280
  try {
1371
1281
  touchInterceptor?.disableGlobalTracking()
1372
1282
  keyboardTracker?.stopTracking()
@@ -1375,7 +1285,6 @@ class RejourneyModuleImpl(
1375
1285
  Logger.warning("[Rejourney] Stop tracking failed: ${e.message}")
1376
1286
  }
1377
1287
 
1378
- // Build metrics
1379
1288
  var crashCount = 0
1380
1289
  var anrCount = 0
1381
1290
  var errorCount = 0
@@ -1388,52 +1297,23 @@ class RejourneyModuleImpl(
1388
1297
  }
1389
1298
  val durationSeconds = ((System.currentTimeMillis() - sessionStartTime) / 1000).toInt()
1390
1299
 
1391
- // Upload remaining events - THIS IS THE CRITICAL HTTP CALL
1392
- Logger.debug("[Rejourney] endSessionSynchronous: Uploading final events (count=${sessionEvents.size})")
1393
- val uploadSuccess = try {
1394
- uploadManager?.uploadBatch(sessionEvents.toList(), isFinal = true) ?: false
1395
- } catch (e: Exception) {
1396
- Logger.warning("[Rejourney] Final upload failed: ${e.message}")
1397
- false
1398
- }
1399
- Logger.debug("[Rejourney] endSessionSynchronous: Upload result=$uploadSuccess")
1300
+ Logger.debug("[Rejourney] endSessionSynchronous: Skipping synchronous upload - relying on EventBuffer and UploadWorker recovery")
1301
+
1302
+ val uploadSuccess = true
1303
+ Logger.debug("[Rejourney] endSessionSynchronous: Upload result=SKIPPED (persisted)")
1400
1304
 
1401
- // Send session end signal - THIS IS THE CRITICAL /session/end CALL
1402
1305
  if (!sessionEndSent) {
1403
1306
  sessionEndSent = true
1404
- Logger.debug("[Rejourney] endSessionSynchronous: Calling /session/end... (sessionId=$sessionId)")
1307
+ Logger.debug("[Rejourney] endSessionSynchronous: Skipping /session/end - UploadWorker will handle recovery (sessionId=$sessionId)")
1405
1308
 
1406
- // CRITICAL: Ensure uploadManager has the correct sessionId
1407
- // Prior handleAppBackground may have cleared it, so we restore it here
1408
- if (sessionId.isNotEmpty()) {
1409
- uploadManager?.sessionId = sessionId
1410
- }
1411
1309
 
1412
- val endSuccess = try {
1413
- uploadManager?.endSession() ?: false
1414
- } catch (e: Exception) {
1415
- Logger.warning("[Rejourney] Session end API call failed: ${e.message}")
1416
- false
1417
- }
1418
- Logger.debug("[Rejourney] endSessionSynchronous: /session/end result=$endSuccess")
1310
+ val endSuccess = true
1311
+ Logger.debug("[Rejourney] endSessionSynchronous: /session/end result=SKIPPED (recovery)")
1419
1312
 
1420
- // Clear recovery markers if successful
1421
- if (endSuccess) {
1422
- try {
1423
- uploadManager?.clearSessionRecovery(sessionId)
1424
- reactContext.getSharedPreferences("rejourney", 0)
1425
- .edit()
1426
- .putLong("rj_session_end_time_$sessionId", System.currentTimeMillis())
1427
- .remove("rj_current_session_id")
1428
- .remove("rj_session_start_time")
1429
- .apply()
1430
- } catch (e: Exception) {
1431
- Logger.warning("[Rejourney] Clear recovery failed: ${e.message}")
1432
- }
1433
- }
1434
1313
  }
1435
1314
 
1436
- // Clear state
1315
+
1316
+
1437
1317
  isRecording = false
1438
1318
  currentSessionId = null
1439
1319
  userId = null
@@ -1459,13 +1339,11 @@ class RejourneyModuleImpl(
1459
1339
  return
1460
1340
  }
1461
1341
 
1462
- // Use saved config from previous session
1463
1342
  val safeUserId = userId ?: "anonymous"
1464
1343
  val safeApiUrl = savedApiUrl.ifEmpty { "https://api.rejourney.co" }
1465
1344
  val safePublicKey = savedPublicKey.ifEmpty { "" }
1466
1345
  val deviceHash = savedDeviceHash
1467
1346
 
1468
- // Setup session
1469
1347
  this.userId = safeUserId
1470
1348
  currentSessionId = sessionId
1471
1349
  sessionStartTime = System.currentTimeMillis()
@@ -1478,41 +1356,34 @@ class RejourneyModuleImpl(
1478
1356
  updateRecordingEligibility(projectSampleRate)
1479
1357
  }
1480
1358
 
1481
- // Save session ID for crash handler
1482
1359
  reactContext.getSharedPreferences("rejourney", 0)
1483
1360
  .edit()
1484
1361
  .putString("rj_current_session_id", currentSessionId)
1485
1362
  .apply()
1486
1363
 
1487
- // Configure upload manager
1488
1364
  uploadManager?.apply {
1489
1365
  this.apiUrl = safeApiUrl
1490
1366
  this.publicKey = safePublicKey
1491
1367
  this.deviceHash = deviceHash
1492
- // NUCLEAR FIX: Use setActiveSessionId() to protect from recovery corruption
1493
1368
  setActiveSessionId(currentSessionId!!)
1494
1369
  this.userId = safeUserId
1495
1370
  this.sessionStartTime = this@RejourneyModuleImpl.sessionStartTime
1496
1371
  resetForNewSession()
1497
1372
  }
1498
1373
 
1499
- // Mark session active
1500
1374
  currentSessionId?.let { sid ->
1501
1375
  uploadManager?.markSessionActive(sid, sessionStartTime)
1502
1376
  }
1503
1377
 
1504
- // Initialize event buffer
1505
1378
  val pendingDir = File(reactContext.cacheDir, "rj_pending")
1506
1379
  currentSessionId?.let { sid ->
1507
1380
  eventBuffer = EventBuffer(reactContext, sid, pendingDir)
1508
1381
  }
1509
1382
 
1510
- // Start capture
1511
1383
  if (remoteRecordingEnabled) {
1512
1384
  captureEngine?.startSession(currentSessionId!!)
1513
1385
  }
1514
1386
 
1515
- // Enable tracking
1516
1387
  touchInterceptor?.enableGlobalTracking()
1517
1388
  keyboardTracker?.startTracking()
1518
1389
  textInputTracker?.startTracking()
@@ -1585,19 +1456,11 @@ class RejourneyModuleImpl(
1585
1456
  if (success) {
1586
1457
  Logger.debug("Device registered: $credentialId")
1587
1458
 
1588
- // Auth succeeded - reset retry state
1589
1459
  resetAuthRetryState()
1590
1460
 
1591
- // Get upload token
1592
1461
  deviceAuthManager?.getUploadToken { tokenSuccess, token, expiresIn, tokenError ->
1593
1462
  if (tokenSuccess) {
1594
- // NOTE: Session recovery is now handled EXCLUSIVELY by WorkManager
1595
- // The old recoverPendingSessions() approach held a mutex that blocked
1596
- // all current session uploads. WorkManager.scheduleRecoveryUpload()
1597
- // runs independently without blocking the current session.
1598
- // Recovery is already scheduled in onLifecycleStart via UploadWorker.scheduleRecoveryUpload()
1599
1463
 
1600
- // Check for pending crash reports
1601
1464
  val crashHandler = CrashHandler.getInstance(reactContext)
1602
1465
  if (crashHandler.hasPendingCrashReport()) {
1603
1466
  crashHandler.loadAndPurgePendingCrashReport()?.let { crashReport ->
@@ -1607,7 +1470,6 @@ class RejourneyModuleImpl(
1607
1470
  }
1608
1471
  }
1609
1472
 
1610
- // Check for pending ANR reports
1611
1473
  val anrHandler = ANRHandler.getInstance(reactContext)
1612
1474
  if (anrHandler.hasPendingANRReport()) {
1613
1475
  anrHandler.loadAndPurgePendingANRReport()?.let { anrReport ->
@@ -1630,18 +1492,13 @@ class RejourneyModuleImpl(
1630
1492
  }
1631
1493
  }
1632
1494
 
1633
- // ==================== Activity Lifecycle Callbacks ====================
1634
- // Note: We prioritize ProcessLifecycleOwner for foreground/background,
1635
- // but onActivityStopped is critical for immediate background detection.
1636
1495
 
1637
1496
  override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
1638
1497
 
1639
1498
  override fun onActivityResumed(activity: Activity) {
1640
- // CRASH PREVENTION: Wrap in try-catch to never crash host app
1641
1499
  try {
1642
1500
  Logger.debug("Activity resumed")
1643
1501
  cancelScheduledBackground()
1644
- // Backup foreground detection - ProcessLifecycleOwner may not always fire
1645
1502
  if (wasInBackground) {
1646
1503
  handleAppForeground("Activity.onResume")
1647
1504
  }
@@ -1651,11 +1508,9 @@ class RejourneyModuleImpl(
1651
1508
  }
1652
1509
 
1653
1510
  override fun onActivityPaused(activity: Activity) {
1654
- // CRASH PREVENTION: Wrap in try-catch to never crash host app
1655
1511
  try {
1656
1512
  Logger.debug("Activity paused (isFinishing=${activity.isFinishing})")
1657
1513
 
1658
- // Force capture immediately in case app is killed from recents
1659
1514
  if (remoteRecordingEnabled) {
1660
1515
  try {
1661
1516
  captureEngine?.forceCaptureWithReason("app_pausing")
@@ -1664,20 +1519,10 @@ class RejourneyModuleImpl(
1664
1519
  }
1665
1520
  }
1666
1521
 
1667
- // LIGHTWEIGHT BACKGROUND PREP for recents detection
1668
- // DO NOT call full handleAppBackground here - that stops the capture engine
1669
- // which causes VideoEncoder race conditions (IllegalStateException: dequeue pending)
1670
- //
1671
- // Instead, we:
1672
- // 1. Set backgroundEntryTime (for 60s timeout calculation)
1673
- // 2. Flush events to disk (so they're persisted if user swipes to kill)
1674
- //
1675
- // Full background handling (stopping capture engine) happens in onActivityStopped/onActivityDestroyed
1676
1522
  if (!wasInBackground && isRecording) {
1677
1523
  Logger.debug("[BG] Activity.onPause: Setting background entry time (capture engine still running)")
1678
1524
  backgroundEntryTime = System.currentTimeMillis()
1679
1525
 
1680
- // Flush events to disk asynchronously
1681
1526
  eventBuffer?.flush()
1682
1527
  Logger.debug("[BG] Activity.onPause: Events flushed to disk, backgroundEntryTime=$backgroundEntryTime")
1683
1528
  }
@@ -1688,10 +1533,8 @@ class RejourneyModuleImpl(
1688
1533
 
1689
1534
  override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
1690
1535
 
1691
- // ==================== DefaultLifecycleObserver (ProcessLifecycleOwner) ====================
1692
1536
 
1693
1537
  override fun onStart(owner: LifecycleOwner) {
1694
- // Backup: if Activity callbacks failed/missed, this catches the app start
1695
1538
  try {
1696
1539
  Logger.debug("ProcessLifecycleOwner: onStart")
1697
1540
  cancelScheduledBackground()
@@ -1704,12 +1547,9 @@ class RejourneyModuleImpl(
1704
1547
  }
1705
1548
 
1706
1549
  override fun onStop(owner: LifecycleOwner) {
1707
- // Backup: catch app background if Activity callbacks missed it
1708
1550
  try {
1709
1551
  Logger.debug("ProcessLifecycleOwner: onStop")
1710
1552
  if (isRecording && !wasInBackground) {
1711
- // If we're recording and haven't detected background yet, do it now
1712
- // ProcessLifecycleOwner is already debounced by AndroidX (700ms), so no extra delay needed
1713
1553
  handleAppBackground("ProcessLifecycle.onStop")
1714
1554
  }
1715
1555
  } catch (e: Exception) {
@@ -1717,10 +1557,8 @@ class RejourneyModuleImpl(
1717
1557
  }
1718
1558
  }
1719
1559
 
1720
- // ==================== ActivityLifecycleCallbacks (Backup/Early Detection) ====================
1721
1560
 
1722
1561
  override fun onActivityStarted(activity: Activity) {
1723
- // CRASH PREVENTION: Wrap in try-catch to never crash host app
1724
1562
  try {
1725
1563
  Logger.debug("Activity started")
1726
1564
  cancelScheduledBackground()
@@ -1733,7 +1571,6 @@ class RejourneyModuleImpl(
1733
1571
  }
1734
1572
 
1735
1573
  override fun onActivityStopped(activity: Activity) {
1736
- // CRASH PREVENTION: Wrap in try-catch to never crash host app
1737
1574
  try {
1738
1575
  if (activity.isChangingConfigurations) {
1739
1576
  Logger.debug("Activity stopped but changing configurations - skipping background")
@@ -1741,14 +1578,10 @@ class RejourneyModuleImpl(
1741
1578
  }
1742
1579
 
1743
1580
  if (activity.isFinishing) {
1744
- // App is closing/killed - IMMEDIATE background handling + FORCE SESSION END
1745
- // Do not use debounce (scheduleBackground) because app kills (swipes) can terminate process instantly
1746
1581
  Logger.debug("Activity stopped and finishing - triggering IMMEDIATE background and ENDING SESSION")
1747
1582
  cancelScheduledBackground()
1748
1583
  handleAppBackground("Activity.onStop:finishing", shouldEndSession = true)
1749
1584
  } else {
1750
- // Normal background - immediate handling (no debounce needed for single activity)
1751
- // BUT do NOT end session (just flush)
1752
1585
  Logger.debug("Activity stopped - triggering IMMEDIATE background")
1753
1586
  cancelScheduledBackground()
1754
1587
  handleAppBackground("Activity.onStop", shouldEndSession = false)
@@ -1760,12 +1593,9 @@ class RejourneyModuleImpl(
1760
1593
  }
1761
1594
 
1762
1595
  override fun onActivityDestroyed(activity: Activity) {
1763
- // CRASH PREVENTION: Wrap in try-catch to never crash host app
1764
1596
  try {
1765
1597
  if (activity.isChangingConfigurations) return
1766
1598
 
1767
- // Redundant backup: ensure background triggered if somehow missed in onStop
1768
- // FORCE SESSION END
1769
1599
  Logger.debug("Activity destroyed (isFinishing=${activity.isFinishing}) - triggering IMMEDIATE background")
1770
1600
  handleAppBackground("Activity.onDestroy", shouldEndSession = true)
1771
1601
 
@@ -1777,8 +1607,6 @@ class RejourneyModuleImpl(
1777
1607
  private fun scheduleBackground(source: String) {
1778
1608
  if (wasInBackground || backgroundScheduled) return
1779
1609
 
1780
- // NOTE: This method is now kept mainly for onPause if we decide to use it,
1781
- // or for legacy debounce logic. Currently onActivityStopped uses immediate handling.
1782
1610
  backgroundScheduled = true
1783
1611
  backgroundEntryTime = System.currentTimeMillis()
1784
1612
 
@@ -1790,7 +1618,7 @@ class RejourneyModuleImpl(
1790
1618
  }
1791
1619
 
1792
1620
  scheduledBackgroundRunnable = runnable
1793
- mainHandler.postDelayed(runnable, 50L) // Reduced to 50ms
1621
+ mainHandler.postDelayed(runnable, 50L)
1794
1622
  }
1795
1623
 
1796
1624
  private fun cancelScheduledBackground() {
@@ -1827,16 +1655,13 @@ class RejourneyModuleImpl(
1827
1655
  Logger.debug("[FG] Session timeout threshold: ${thresholdSec}s")
1828
1656
  Logger.debug("[FG] Current totalBackgroundTimeMs: $totalBackgroundTimeMs")
1829
1657
 
1830
- // Reset background tracking state immediately (like iOS)
1831
1658
  wasInBackground = false
1832
1659
  backgroundEntryTime = 0
1833
1660
 
1834
1661
  if (bgDurationMs >= sessionTimeoutMs) {
1835
- // === TIMEOUT CASE: End old session, start new one ===
1836
1662
  Logger.debug("[FG] TIMEOUT: ${bgDurationSec}s >= ${thresholdSec}s → Creating NEW session")
1837
1663
  handleSessionTimeoutOnForeground(bgDurationMs, source)
1838
1664
  } else {
1839
- // === SHORT BACKGROUND: Resume same session ===
1840
1665
  Logger.debug("[FG] SHORT BACKGROUND: ${bgDurationSec}s < ${thresholdSec}s → Resuming SAME session")
1841
1666
  handleShortBackgroundResume(bgDurationMs, source)
1842
1667
  }
@@ -1860,11 +1685,9 @@ class RejourneyModuleImpl(
1860
1685
 
1861
1686
  Logger.debug("SESSION TIMEOUT: Ending session $oldSessionId after ${bgDurationMs/1000}s in background")
1862
1687
 
1863
- // Add final background time to accumulated total before ending
1864
1688
  totalBackgroundTimeMs += bgDurationMs
1865
1689
  uploadManager?.totalBackgroundTimeMs = totalBackgroundTimeMs
1866
1690
 
1867
- // Stop all capture/tracking immediately (synchronous, like iOS)
1868
1691
  try {
1869
1692
  stopBatchUploadTimer()
1870
1693
  stopDurationLimitTimer()
@@ -1876,25 +1699,17 @@ class RejourneyModuleImpl(
1876
1699
  Logger.warning("Error stopping capture during session timeout: ${e.message}")
1877
1700
  }
1878
1701
 
1879
- // Mark as not recording to prevent race conditions
1880
1702
  isRecording = false
1881
1703
 
1882
- // Handle old session end and new session start asynchronously using backgroundScope
1883
- // which survives independently of the main scope and won't be cancelled on background
1884
1704
  backgroundScope.launch {
1885
- // Use NonCancellable context to ensure critical recovery operations complete
1886
- // even if the coroutine is cancelled (app goes to background again)
1887
1705
  withContext(NonCancellable) {
1888
1706
  try {
1889
- // CRITICAL: Ensure auth token is valid before uploading
1890
- // Token may have expired during the 60+ seconds in background
1891
1707
  try {
1892
1708
  DeviceAuthManager.getInstance(reactContext).ensureValidToken()
1893
1709
  } catch (e: Exception) {
1894
1710
  Logger.warning("Failed to refresh auth token during session timeout: ${e.message}")
1895
1711
  }
1896
1712
 
1897
- // Add session_timeout event to old session's events
1898
1713
  val timeoutEvent = mapOf(
1899
1714
  "type" to EventType.SESSION_TIMEOUT,
1900
1715
  "timestamp" to System.currentTimeMillis(),
@@ -1904,7 +1719,6 @@ class RejourneyModuleImpl(
1904
1719
  )
1905
1720
  sessionEvents.add(timeoutEvent)
1906
1721
 
1907
- // Upload old session's events as final and call session/end
1908
1722
  val finalEvents = sessionEvents.toList()
1909
1723
  sessionEvents.clear()
1910
1724
 
@@ -1916,8 +1730,6 @@ class RejourneyModuleImpl(
1916
1730
  }
1917
1731
  }
1918
1732
 
1919
- // End the old session (calls /session/end which triggers promotion)
1920
- // CRITICAL: Pass oldSessionId explicitly since uploadManager.sessionId may be reset
1921
1733
  var endSessionSuccess = false
1922
1734
  if (!sessionEndSent) {
1923
1735
  sessionEndSent = true
@@ -1928,7 +1740,6 @@ class RejourneyModuleImpl(
1928
1740
  }
1929
1741
  }
1930
1742
 
1931
- // Clear recovery markers for old session
1932
1743
  try {
1933
1744
  uploadManager?.clearSessionRecovery(oldSessionId)
1934
1745
  } catch (e: Exception) {
@@ -1941,53 +1752,39 @@ class RejourneyModuleImpl(
1941
1752
  Logger.warning("Old session $oldSessionId end signal failed - will be recovered on next launch")
1942
1753
  }
1943
1754
 
1944
- // === START NEW SESSION ===
1945
1755
  val timestamp = System.currentTimeMillis()
1946
1756
  val shortUuid = UUID.randomUUID().toString().take(8).uppercase()
1947
1757
  val newSessionId = "session_${timestamp}_$shortUuid"
1948
1758
 
1949
- // Reset state for new session
1950
1759
  currentSessionId = newSessionId
1951
1760
  sessionStartTime = timestamp
1952
1761
  totalBackgroundTimeMs = 0
1953
1762
  sessionEndSent = false
1954
1763
 
1955
- // Reset upload manager for new session
1956
1764
  uploadManager?.let { um ->
1957
- // NUCLEAR FIX: Use setActiveSessionId() to update both sessionId AND activeSessionId
1958
- // This ensures the new session doesn't get merged into the old one's recovery path
1959
1765
  um.setActiveSessionId(newSessionId)
1960
1766
 
1961
1767
  um.sessionStartTime = timestamp
1962
1768
  um.totalBackgroundTimeMs = 0
1963
1769
 
1964
- // FIX: Synchronize user identity and config to UploadManager
1965
- // This matches iOS behavior and ensures robustness if memory was cleared
1966
1770
  um.userId = userId ?: "anonymous"
1967
1771
 
1968
- // Restore saved config if available
1969
1772
  if (savedDeviceHash.isNotEmpty()) um.deviceHash = savedDeviceHash
1970
1773
  if (savedPublicKey.isNotEmpty()) um.publicKey = savedPublicKey
1971
1774
  if (savedApiUrl.isNotEmpty()) um.apiUrl = savedApiUrl
1972
1775
 
1973
- // CRITICAL: Create session metadata file for crash recovery
1974
1776
  um.markSessionActive(newSessionId, timestamp)
1975
1777
  }
1976
1778
 
1977
- // CRITICAL: Save new session ID to SharedPreferences for unclosed session detection
1978
- // Use commit() instead of apply() to ensure synchronous write before app kill
1979
1779
  reactContext.getSharedPreferences("rejourney", 0)
1980
1780
  .edit()
1981
1781
  .putString("rj_current_session_id", newSessionId)
1982
1782
  .putLong("rj_session_start_time", timestamp)
1983
1783
  .commit()
1984
1784
 
1985
- // CRITICAL: Re-initialize EventBuffer for new session
1986
- // Without this, events are written to wrong session's file
1987
1785
  val pendingDir = java.io.File(reactContext.cacheDir, "rj_pending")
1988
1786
  eventBuffer = EventBuffer(reactContext, newSessionId, pendingDir)
1989
1787
 
1990
- // Start capture for new session (run on main thread for UI safety)
1991
1788
  withContext(Dispatchers.Main) {
1992
1789
  try {
1993
1790
  resetSamplingDecision()
@@ -2000,14 +1797,10 @@ class RejourneyModuleImpl(
2000
1797
  captureEngine?.startSession(newSessionId)
2001
1798
  }
2002
1799
 
2003
- // Re-enable tracking
2004
1800
  touchInterceptor?.enableGlobalTracking()
2005
1801
  keyboardTracker?.startTracking()
2006
1802
  textInputTracker?.startTracking()
2007
1803
 
2008
- // CRITICAL: Restart SessionLifecycleService for the new session
2009
- // The system destroys it after ~60 seconds in background
2010
- // Without this, onTaskRemoved won't be called and session won't end properly
2011
1804
  try {
2012
1805
  val serviceIntent = Intent(reactContext, SessionLifecycleService::class.java)
2013
1806
  reactContext.startService(serviceIntent)
@@ -2022,7 +1815,6 @@ class RejourneyModuleImpl(
2022
1815
 
2023
1816
  isRecording = true
2024
1817
 
2025
- // Add session_start event for new session
2026
1818
  val sessionStartEvent = mapOf(
2027
1819
  "type" to EventType.SESSION_START,
2028
1820
  "timestamp" to System.currentTimeMillis(),
@@ -2033,11 +1825,9 @@ class RejourneyModuleImpl(
2033
1825
  )
2034
1826
  addEventWithPersistence(sessionStartEvent)
2035
1827
 
2036
- // Start timers for new session
2037
1828
  startBatchUploadTimer()
2038
1829
  startDurationLimitTimer()
2039
1830
 
2040
- // Trigger immediate upload to register new session
2041
1831
  delay(100)
2042
1832
  try {
2043
1833
  performBatchUpload()
@@ -2048,15 +1838,11 @@ class RejourneyModuleImpl(
2048
1838
  Logger.debug("New session $newSessionId started (previous: $oldSessionId)")
2049
1839
 
2050
1840
  } catch (e: CancellationException) {
2051
- // Coroutine was cancelled but NonCancellable should prevent this
2052
- // Log as warning, not error - this is expected if app is killed
2053
1841
  Logger.warning("Session timeout recovery interrupted: ${e.message}")
2054
- // Ensure recording state is consistent
2055
1842
  isRecording = true
2056
1843
  startBatchUploadTimer()
2057
1844
  } catch (e: Exception) {
2058
1845
  Logger.error("Failed to handle session timeout", e)
2059
- // Attempt recovery - restart recording
2060
1846
  isRecording = true
2061
1847
  startBatchUploadTimer()
2062
1848
  }
@@ -2068,7 +1854,6 @@ class RejourneyModuleImpl(
2068
1854
  * Handle short background return (< 60s) - resume same session.
2069
1855
  */
2070
1856
  private fun handleShortBackgroundResume(bgDurationMs: Long, source: String) {
2071
- // Accumulate background time for billing exclusion
2072
1857
  val previousBgTime = totalBackgroundTimeMs
2073
1858
  totalBackgroundTimeMs += bgDurationMs
2074
1859
  uploadManager?.totalBackgroundTimeMs = totalBackgroundTimeMs
@@ -2084,7 +1869,6 @@ class RejourneyModuleImpl(
2084
1869
  return
2085
1870
  }
2086
1871
 
2087
- // Log foreground event for replay player
2088
1872
  addEventWithPersistence(
2089
1873
  mapOf(
2090
1874
  "type" to EventType.APP_FOREGROUND,
@@ -2095,7 +1879,6 @@ class RejourneyModuleImpl(
2095
1879
  )
2096
1880
  )
2097
1881
 
2098
- // Resume capture and tracking
2099
1882
  if (remoteRecordingEnabled) {
2100
1883
  try {
2101
1884
  captureEngine?.startSession(currentSessionId!!)
@@ -2125,7 +1908,6 @@ class RejourneyModuleImpl(
2125
1908
  * upload remaining pending data and close the session via session/end.
2126
1909
  */
2127
1910
  private fun handleAppBackground(source: String, shouldEndSession: Boolean = false) {
2128
- // Prevent duplicate background handling (unless forcing end)
2129
1911
  if (wasInBackground && !shouldEndSession) {
2130
1912
  Logger.debug("[BG] Already in background, skipping duplicate handling")
2131
1913
  return
@@ -2136,42 +1918,33 @@ class RejourneyModuleImpl(
2136
1918
 
2137
1919
  if (isRecording && !isShuttingDown) {
2138
1920
  wasInBackground = true
2139
- // backgroundEntryTime is already set by debounce scheduling
2140
1921
  if (backgroundEntryTime == 0L) {
2141
1922
  backgroundEntryTime = System.currentTimeMillis()
2142
1923
  }
2143
1924
  Logger.debug("[BG] backgroundEntryTime set to $backgroundEntryTime")
2144
1925
  Logger.debug("[BG] Current totalBackgroundTimeMs=$totalBackgroundTimeMs")
2145
1926
 
2146
- // Stop timers (but don't cancel in-progress uploads)
2147
1927
  stopBatchUploadTimer()
2148
1928
  stopDurationLimitTimer()
2149
1929
 
2150
- // Stop tracking
2151
1930
  keyboardTracker?.stopTracking()
2152
1931
  textInputTracker?.stopTracking()
2153
1932
  touchInterceptor?.disableGlobalTracking()
2154
1933
 
2155
- // Add background event
2156
1934
  val event = mapOf(
2157
1935
  "type" to EventType.APP_BACKGROUND,
2158
1936
  "timestamp" to System.currentTimeMillis()
2159
1937
  )
2160
1938
  addEventWithPersistence(event)
2161
1939
 
2162
- // CRITICAL: Ensure all in-memory events are written to disk before scheduling upload
2163
- // EventBuffer uses async writes, so we need to flush to ensure all writes complete
2164
1940
  Logger.debug("[BG] ===== ENSURING ALL EVENTS ARE PERSISTED TO DISK =====")
2165
1941
  Logger.debug("[BG] In-memory events count: ${sessionEvents.size}")
2166
1942
  Logger.debug("[BG] Event types in memory: ${sessionEvents.map { it["type"] }.joinToString(", ")}")
2167
1943
 
2168
- // Log event buffer state before flush
2169
1944
  eventBuffer?.let { buffer ->
2170
1945
  Logger.debug("[BG] EventBuffer state: eventCount=${buffer.eventCount}, fileExists=${File(reactContext.cacheDir, "rj_pending/$currentSessionId/events.jsonl").exists()}")
2171
1946
  } ?: Logger.warning("[BG] EventBuffer is NULL - cannot flush events!")
2172
1947
 
2173
- // Flush all pending writes to disk
2174
- // This drains the async write queue and ensures all events are on disk
2175
1948
  val flushStartTime = System.currentTimeMillis()
2176
1949
  val flushSuccess = eventBuffer?.flush() ?: false
2177
1950
  val flushDuration = System.currentTimeMillis() - flushStartTime
@@ -2180,7 +1953,6 @@ class RejourneyModuleImpl(
2180
1953
  Logger.debug("[BG] ✅ Events flushed to disk successfully in ${flushDuration}ms")
2181
1954
  Logger.debug("[BG] In-memory events: ${sessionEvents.size}, EventBuffer eventCount: ${eventBuffer?.eventCount ?: 0}")
2182
1955
 
2183
- // Verify file exists and has content
2184
1956
  val eventsFile = File(reactContext.cacheDir, "rj_pending/$currentSessionId/events.jsonl")
2185
1957
  if (eventsFile.exists()) {
2186
1958
  val fileSize = eventsFile.length()
@@ -2194,15 +1966,12 @@ class RejourneyModuleImpl(
2194
1966
  }
2195
1967
  Logger.debug("[BG] ===== EVENT PERSISTENCE CHECK COMPLETE =====")
2196
1968
 
2197
- // Stop capture engine while backgrounded (triggers final segment flush and hierarchy upload)
2198
1969
  if (remoteRecordingEnabled) {
2199
1970
  Logger.debug("[BG] ===== STOPPING CAPTURE ENGINE =====")
2200
1971
 
2201
1972
  if (shouldEndSession) {
2202
- // Kill scenario: use emergency flush to save crash metadata and stop ASAP
2203
1973
  Logger.debug("[BG] Force kill detected - using emergency flush")
2204
1974
  captureEngine?.emergencyFlush()
2205
- // Continue to stopSession for hierarchy upload and other cleanup
2206
1975
  }
2207
1976
 
2208
1977
  Logger.debug("[BG] Stopping capture engine for background (sessionId=$currentSessionId)")
@@ -2210,62 +1979,45 @@ class RejourneyModuleImpl(
2210
1979
  Logger.debug("[BG] Capture engine stopSession() called")
2211
1980
  }
2212
1981
 
2213
- // Update session recovery meta so WorkManager can find the session
2214
1982
  currentSessionId?.let { sid ->
2215
1983
  uploadManager?.updateSessionRecoveryMeta(sid)
2216
1984
  Logger.debug("[BG] Session recovery metadata updated for: $sid")
2217
1985
  }
2218
1986
 
2219
- // ===== SIMPLIFIED UPLOAD: WorkManager Only =====
2220
- // Industry-standard approach: persist first (done above), then schedule background worker.
2221
- // NO synchronous uploads - they cause ANRs and mutex contention with recovery.
2222
- // WorkManager is reliable for background uploads when properly configured.
2223
1987
  currentSessionId?.let { sid ->
2224
1988
  Logger.debug("[BG] ===== SCHEDULING WORKMANAGER UPLOAD =====")
2225
1989
  Logger.debug("[BG] Session: $sid, Events persisted: ${eventBuffer?.eventCount ?: 0}, isFinal: $shouldEndSession")
2226
1990
 
2227
- // Clear in-memory events since they're persisted to disk
2228
- // WorkManager will read from disk
2229
1991
  sessionEvents.clear()
2230
1992
 
2231
- // Schedule expedited upload via WorkManager
2232
1993
  UploadWorker.scheduleUpload(
2233
1994
  context = reactContext,
2234
1995
  sessionId = sid,
2235
- isFinal = shouldEndSession, // Pass shouldEndSession as isFinal
2236
- expedited = true // Request expedited execution
1996
+ isFinal = shouldEndSession,
1997
+ expedited = true
2237
1998
  )
2238
1999
  Logger.debug("[BG] ✅ WorkManager upload scheduled for session: $sid")
2239
2000
 
2240
- // NEW: Best-effort immediate upload (Fire-and-Forget)
2241
- // Try to upload immediately while app is still alive in memory.
2242
- // If this succeeds, WorkManager will find nothing to do (which is fine).
2243
- // If this fails/gets killed, WorkManager will pick it up.
2244
- // This mimics iOS "beginBackgroundTask" pattern.
2245
2001
  scope.launch(Dispatchers.IO) {
2246
2002
  try {
2247
2003
  Logger.debug("[BG] 🚀 Attempting immediate best-effort upload for $sid")
2248
2004
 
2249
- // Create a temporary UploadManager because the main one's state is complex
2250
- // We use the same parameters as WorkManager creates
2251
2005
  val authManager = DeviceAuthManager.getInstance(reactContext)
2252
2006
  val apiUrl = authManager.getCurrentApiUrl() ?: "https://api.rejourney.co"
2253
2007
 
2254
2008
  val bgUploader = com.rejourney.network.UploadManager(reactContext, apiUrl).apply {
2255
2009
  this.sessionId = sid
2256
- this.setActiveSessionId(sid) // CRITICAL: Set active session ID
2010
+ this.setActiveSessionId(sid)
2257
2011
  this.publicKey = authManager.getCurrentPublicKey() ?: ""
2258
2012
  this.deviceHash = authManager.getCurrentDeviceHash() ?: ""
2259
2013
  this.sessionStartTime = uploadManager?.sessionStartTime ?: 0L
2260
2014
  this.totalBackgroundTimeMs = uploadManager?.totalBackgroundTimeMs ?: 0L
2261
2015
  }
2262
2016
 
2263
- // Read events from disk since we flushed them
2264
2017
  val eventBufferDir = File(reactContext.cacheDir, "rj_pending/$sid")
2265
2018
  val eventsFile = File(eventBufferDir, "events.jsonl")
2266
2019
 
2267
2020
  if (eventsFile.exists()) {
2268
- // Read events - duplicated logic from UploadWorker but necessary for successful off-main-thread upload
2269
2021
  val events = mutableListOf<Map<String, Any?>>()
2270
2022
  eventsFile.bufferedReader().useLines { lines ->
2271
2023
  lines.forEach { line ->
@@ -2287,11 +2039,9 @@ class RejourneyModuleImpl(
2287
2039
  val success = bgUploader.uploadBatch(events, isFinal = shouldEndSession)
2288
2040
  if (success) {
2289
2041
  Logger.debug("[BG] ✅ Immediate upload SUCCESS! Cleaning up disk...")
2290
- // Clean up so WorkManager doesn't re-upload
2291
2042
  eventsFile.delete()
2292
2043
  File(eventBufferDir, "buffer_meta.json").delete()
2293
2044
 
2294
- // IF FINAL, END SESSION IMMEDIATELY
2295
2045
  if (shouldEndSession) {
2296
2046
  Logger.debug("[BG] Immediate upload was final, ending session...")
2297
2047
  bgUploader.endSession()
@@ -2300,12 +2050,10 @@ class RejourneyModuleImpl(
2300
2050
  Logger.warning("[BG] Immediate upload failed - leaving for WorkManager")
2301
2051
  }
2302
2052
  } else if (shouldEndSession) {
2303
- // Even if no events, if it's final, we should try to end session
2304
2053
  Logger.debug("[BG] No events but shouldEndSession=true, ending session...")
2305
2054
  bgUploader.endSession()
2306
2055
  }
2307
2056
  } else if (shouldEndSession) {
2308
- // Even if no event file, if it's final, we should try to end session
2309
2057
  Logger.debug("[BG] No event file but shouldEndSession=true, ending session...")
2310
2058
  bgUploader.endSession()
2311
2059
  }
@@ -2313,18 +2061,14 @@ class RejourneyModuleImpl(
2313
2061
  Logger.error("[BG] Immediate upload error: ${e.message} - WorkManager will handle it")
2314
2062
  }
2315
2063
  }
2316
- } // End of currentSessionId?.let
2064
+ }
2317
2065
  } else {
2318
2066
  Logger.debug("[BG] Skipping background handling (isRecording=$isRecording, isShuttingDown=$isShuttingDown)")
2319
2067
  }
2320
2068
  }
2321
2069
 
2322
- // ==================== TouchInterceptorDelegate ====================
2323
2070
 
2324
2071
  override fun onTouchEvent(event: MotionEvent, gestureType: String?) {
2325
- // We rely primarily on onGestureRecognized, but can add raw touches if needed.
2326
- // For now, to match iOS "touches visited", we can treat simple taps here if needed,
2327
- // but gestureClassifier usually handles it.
2328
2072
  }
2329
2073
 
2330
2074
  override fun onGestureRecognized(gestureType: String, x: Float, y: Float, details: Map<String, Any?>) {
@@ -2333,8 +2077,6 @@ class RejourneyModuleImpl(
2333
2077
  try {
2334
2078
  val timestamp = System.currentTimeMillis()
2335
2079
 
2336
- // Build touches array matching iOS format for web player compatibility
2337
- // Web player filters events without touches array (e.touches.length > 0)
2338
2080
  val touchPoint = mapOf(
2339
2081
  "x" to x,
2340
2082
  "y" to y,
@@ -2346,15 +2088,14 @@ class RejourneyModuleImpl(
2346
2088
  "type" to EventType.GESTURE,
2347
2089
  "timestamp" to timestamp,
2348
2090
  "gestureType" to gestureType,
2349
- "touches" to listOf(touchPoint), // Required by web player TouchOverlay
2091
+ "touches" to listOf(touchPoint),
2350
2092
  "duration" to (details["duration"] ?: 0),
2351
2093
  "targetLabel" to details["targetLabel"],
2352
- "x" to x, // Keep for backwards compatibility
2094
+ "x" to x,
2353
2095
  "y" to y,
2354
2096
  "details" to details
2355
2097
  )
2356
2098
 
2357
- // Debug logging to verify touch events are captured correctly for web overlay
2358
2099
  Logger.debug("[TOUCH] Gesture recorded: type=$gestureType, x=$x, y=$y, touches=${listOf(touchPoint)}")
2359
2100
 
2360
2101
  addEventWithPersistence(eventMap)
@@ -2405,7 +2146,6 @@ class RejourneyModuleImpl(
2405
2146
  try {
2406
2147
  val timestamp = System.currentTimeMillis()
2407
2148
 
2408
- // Build touches array matching iOS format for web player compatibility
2409
2149
  val touchPoint = mapOf(
2410
2150
  "x" to x,
2411
2151
  "y" to y,
@@ -2414,10 +2154,10 @@ class RejourneyModuleImpl(
2414
2154
  )
2415
2155
 
2416
2156
  val eventMap = mapOf(
2417
- "type" to EventType.GESTURE, // Use gesture type for web player compatibility
2157
+ "type" to EventType.GESTURE,
2418
2158
  "timestamp" to timestamp,
2419
2159
  "gestureType" to "rage_tap",
2420
- "touches" to listOf(touchPoint), // Required by web player TouchOverlay
2160
+ "touches" to listOf(touchPoint),
2421
2161
  "tapCount" to tapCount,
2422
2162
  "x" to x,
2423
2163
  "y" to y
@@ -2434,7 +2174,6 @@ class RejourneyModuleImpl(
2434
2174
 
2435
2175
  override fun currentKeyboardHeight(): Int = lastKeyboardHeight
2436
2176
 
2437
- // ==================== NetworkMonitorListener ====================
2438
2177
 
2439
2178
  override fun onNetworkChanged(quality: com.rejourney.network.NetworkQuality) {
2440
2179
  if (!isRecording) return
@@ -2459,7 +2198,6 @@ class RejourneyModuleImpl(
2459
2198
  addEventWithPersistence(eventMap)
2460
2199
  }
2461
2200
 
2462
- // ==================== KeyboardTrackerListener ====================
2463
2201
 
2464
2202
  override fun onKeyboardShown(keyboardHeight: Int) {
2465
2203
  if (!isRecording) return
@@ -2475,7 +2213,6 @@ class RejourneyModuleImpl(
2475
2213
  )
2476
2214
  addEventWithPersistence(eventMap)
2477
2215
 
2478
- // Schedule capture after keyboard settles
2479
2216
  captureEngine?.notifyKeyboardEvent("keyboard_shown")
2480
2217
  }
2481
2218
 
@@ -2485,7 +2222,6 @@ class RejourneyModuleImpl(
2485
2222
  Logger.debug("[KEYBOARD] Keyboard hidden (keyPresses=$keyPressCount)")
2486
2223
  isKeyboardVisible = false
2487
2224
 
2488
- // Match iOS/player behavior: emit a recent typing signal if we recorded keypresses
2489
2225
  if (keyPressCount > 0) {
2490
2226
  addEventWithPersistence(
2491
2227
  mapOf(
@@ -2503,10 +2239,8 @@ class RejourneyModuleImpl(
2503
2239
  )
2504
2240
  addEventWithPersistence(eventMap)
2505
2241
 
2506
- // Reset key press count when keyboard hides
2507
2242
  keyPressCount = 0
2508
2243
 
2509
- // Schedule capture after keyboard settles
2510
2244
  captureEngine?.notifyKeyboardEvent("keyboard_hidden")
2511
2245
  }
2512
2246
 
@@ -2514,17 +2248,13 @@ class RejourneyModuleImpl(
2514
2248
  keyPressCount++
2515
2249
  }
2516
2250
 
2517
- // ==================== TextInputTrackerListener ====================
2518
2251
 
2519
2252
  override fun onTextChanged(characterCount: Int) {
2520
2253
  if (!isRecording) return
2521
2254
  if (characterCount <= 0) return
2522
2255
 
2523
- // Accumulate key presses
2524
2256
  keyPressCount += characterCount
2525
2257
 
2526
- // Emit typing events so the player can animate typing indicators.
2527
- // (No actual text content is captured.)
2528
2258
  if (isKeyboardVisible) {
2529
2259
  addEventWithPersistence(
2530
2260
  mapOf(
@@ -2536,16 +2266,13 @@ class RejourneyModuleImpl(
2536
2266
  }
2537
2267
  }
2538
2268
 
2539
- // ==================== ANRHandler.ANRListener ====================
2540
2269
 
2541
2270
  override fun onANRDetected(durationMs: Long, threadState: String?) {
2542
- // CRASH PREVENTION: Wrap in try-catch to never crash host app
2543
2271
  try {
2544
2272
  if (!isRecording) return
2545
2273
 
2546
2274
  Logger.debug("ANR callback: duration=${durationMs}ms")
2547
2275
 
2548
- // Log ANR as an event for timeline display
2549
2276
  val eventMap = mutableMapOf<String, Any?>(
2550
2277
  "type" to "anr",
2551
2278
  "timestamp" to System.currentTimeMillis(),
@@ -2554,19 +2281,15 @@ class RejourneyModuleImpl(
2554
2281
  threadState?.let { eventMap["threadState"] = it }
2555
2282
  addEventWithPersistence(eventMap)
2556
2283
 
2557
- // Increment telemetry counter
2558
2284
  Telemetry.getInstance().recordANR()
2559
2285
  } catch (e: Exception) {
2560
2286
  Logger.error("SDK error in onANRDetected (non-fatal)", e)
2561
2287
  }
2562
2288
  }
2563
2289
 
2564
- // ==================== CaptureEngineDelegate ====================
2565
2290
 
2566
2291
  override fun onSegmentReady(segmentFile: File, startTime: Long, endTime: Long, frameCount: Int) {
2567
- // CRITICAL FIX: Do NOT delete segment if shutting down - we want to persist it for recovery!
2568
2292
  if (!isRecording && !isShuttingDown) {
2569
- // Clean up the segment file if we're not recording (and not shutting down)
2570
2293
  try {
2571
2294
  segmentFile.delete()
2572
2295
  } catch (_: Exception) {}
@@ -2575,7 +2298,6 @@ class RejourneyModuleImpl(
2575
2298
 
2576
2299
  if (isShuttingDown) {
2577
2300
  Logger.debug("Segment ready during shutdown - preserving file for recovery: ${segmentFile.name}")
2578
- // Do not attempt upload now as scope is cancelled. WorkManager/Recovery will handle it.
2579
2301
  return
2580
2302
  }
2581
2303
 
@@ -2612,7 +2334,6 @@ class RejourneyModuleImpl(
2612
2334
  override fun onCaptureError(error: Exception) {
2613
2335
  Logger.error("Capture error: ${error.message}", error)
2614
2336
 
2615
- // Log capture error as an event
2616
2337
  val eventMap = mutableMapOf<String, Any?>(
2617
2338
  "type" to "capture_error",
2618
2339
  "timestamp" to System.currentTimeMillis(),
@@ -2635,9 +2356,6 @@ class RejourneyModuleImpl(
2635
2356
  return
2636
2357
  }
2637
2358
 
2638
- // CRITICAL FIX: Capture current session ID at callback time
2639
- // This prevents stale session ID issues where UploadManager.sessionId
2640
- // may still reference a previous session
2641
2359
  val sid = currentSessionId ?: run {
2642
2360
  Logger.error("[HIERARCHY] onHierarchySnapshotsReady: No current session ID, cannot upload hierarchy")
2643
2361
  return
@@ -2654,7 +2372,7 @@ class RejourneyModuleImpl(
2654
2372
  val success = uploadManager?.uploadHierarchy(
2655
2373
  hierarchyData = snapshotsJson,
2656
2374
  timestamp = timestamp,
2657
- sessionId = sid // Pass session ID explicitly
2375
+ sessionId = sid
2658
2376
  ) ?: false
2659
2377
 
2660
2378
  val uploadDuration = System.currentTimeMillis() - uploadStartTime
@@ -2670,7 +2388,6 @@ class RejourneyModuleImpl(
2670
2388
  }
2671
2389
  }
2672
2390
 
2673
- // ==================== AuthFailureListener ====================
2674
2391
 
2675
2392
  /**
2676
2393
  * Called when authentication fails due to security errors (403/404).
@@ -2683,14 +2400,11 @@ class RejourneyModuleImpl(
2683
2400
 
2684
2401
  when (errorCode) {
2685
2402
  403 -> {
2686
- // SECURITY: Package name mismatch or access forbidden - PERMANENT failure
2687
2403
  Logger.error("SECURITY: Access forbidden - stopping recording permanently")
2688
2404
  authPermanentlyFailed = true
2689
2405
  handleAuthenticationFailurePermanent(errorCode, errorMessage, domain)
2690
2406
  }
2691
2407
  else -> {
2692
- // 404 and other errors - retry with exponential backoff
2693
- // Recording continues locally, events queued for later upload
2694
2408
  scheduleAuthRetry(errorCode, errorMessage, domain)
2695
2409
  }
2696
2410
  }
@@ -2707,19 +2421,15 @@ class RejourneyModuleImpl(
2707
2421
 
2708
2422
  authRetryCount++
2709
2423
 
2710
- // Check max retries
2711
2424
  if (authRetryCount > MAX_AUTH_RETRIES) {
2712
2425
  Logger.error("Auth failed after $MAX_AUTH_RETRIES retries. Recording continues locally.")
2713
2426
 
2714
- // Emit warning (not error) - recording continues
2715
2427
  emitAuthWarningEvent(errorCode, "Auth failed after max retries. Recording locally.", authRetryCount)
2716
2428
 
2717
- // Schedule long background retry (5 minutes)
2718
2429
  scheduleBackgroundAuthRetry(AUTH_BACKGROUND_RETRY_DELAY_MS)
2719
2430
  return
2720
2431
  }
2721
2432
 
2722
- // Calculate exponential backoff: 2s, 4s, 8s, 16s, 32s, capped at 60s
2723
2433
  val delay = minOf(
2724
2434
  AUTH_RETRY_BASE_DELAY_MS * (1L shl (authRetryCount - 1)),
2725
2435
  AUTH_RETRY_MAX_DELAY_MS
@@ -2728,7 +2438,6 @@ class RejourneyModuleImpl(
2728
2438
  Logger.info("Auth failed (attempt $authRetryCount/$MAX_AUTH_RETRIES), retrying in ${delay}ms. " +
2729
2439
  "Recording continues locally. Error: $errorMessage")
2730
2440
 
2731
- // After 2 failed attempts, clear cached auth data and re-register fresh
2732
2441
  if (authRetryCount >= 2) {
2733
2442
  Logger.info("Clearing cached auth data and re-registering fresh...")
2734
2443
  deviceAuthManager?.clearCredentials()
@@ -2741,7 +2450,6 @@ class RejourneyModuleImpl(
2741
2450
  * Schedule a background auth retry after specified delay.
2742
2451
  */
2743
2452
  private fun scheduleBackgroundAuthRetry(delayMs: Long) {
2744
- // Cancel any existing retry job
2745
2453
  authRetryJob?.cancel()
2746
2454
 
2747
2455
  authRetryJob = scope.launch {
@@ -2768,7 +2476,6 @@ class RejourneyModuleImpl(
2768
2476
  if (success) {
2769
2477
  Logger.debug("Auth retry successful: device registered: $credentialId")
2770
2478
  resetAuthRetryState()
2771
- // Get upload token after successful registration
2772
2479
  deviceAuthManager?.getUploadToken { tokenSuccess, token, expiresIn, tokenError ->
2773
2480
  if (tokenSuccess) {
2774
2481
  Logger.debug("Upload token obtained after auth retry")
@@ -2798,38 +2505,29 @@ class RejourneyModuleImpl(
2798
2505
  * Stops recording, clears credentials, and emits error event to JS.
2799
2506
  */
2800
2507
  private fun handleAuthenticationFailurePermanent(errorCode: Int, errorMessage: String, domain: String) {
2801
- // Must run on main thread for React Native event emission
2802
2508
  Handler(Looper.getMainLooper()).post {
2803
2509
  try {
2804
- // Stop recording immediately
2805
2510
  if (isRecording) {
2806
2511
  Logger.warning("Stopping recording due to security authentication failure")
2807
2512
 
2808
- // Stop capture engine
2809
2513
  captureEngine?.stopSession()
2810
2514
 
2811
- // Disable touch tracking
2812
2515
  touchInterceptor?.disableGlobalTracking()
2813
2516
 
2814
- // Stop keyboard and text input tracking
2815
2517
  keyboardTracker?.stopTracking()
2816
2518
  textInputTracker?.stopTracking()
2817
2519
 
2818
- // Stop timers
2819
2520
  stopBatchUploadTimer()
2820
2521
  stopDurationLimitTimer()
2821
2522
 
2822
- // Clear session state
2823
2523
  isRecording = false
2824
2524
  currentSessionId = null
2825
2525
  userId = null
2826
2526
  sessionEvents.clear()
2827
2527
  }
2828
2528
 
2829
- // Clear stored credentials
2830
2529
  deviceAuthManager?.clearCredentials()
2831
2530
 
2832
- // Emit error event to JavaScript layer
2833
2531
  emitAuthErrorEvent(errorCode, errorMessage, domain)
2834
2532
 
2835
2533
  } catch (e: Exception) {
@@ -2886,7 +2584,6 @@ class RejourneyModuleImpl(
2886
2584
  */
2887
2585
  private fun checkPreviousAppKill() {
2888
2586
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
2889
- // ApplicationExitInfo is only available on Android 11+ (API 30)
2890
2587
  return
2891
2588
  }
2892
2589
 
@@ -2897,7 +2594,6 @@ class RejourneyModuleImpl(
2897
2594
  return
2898
2595
  }
2899
2596
 
2900
- // Get historical exit reasons for this process
2901
2597
  val exitReasons = activityManager.getHistoricalProcessExitReasons(null, 0, 1)
2902
2598
 
2903
2599
  if (exitReasons.isNotEmpty()) {
@@ -2907,11 +2603,8 @@ class RejourneyModuleImpl(
2907
2603
 
2908
2604
  Logger.debug("Previous app exit: reason=$reason, timestamp=$timestamp")
2909
2605
 
2910
- // Check if app was killed by user (swipe away, force stop, etc.)
2911
- // REASON_USER_REQUESTED includes swipe-away from recent apps
2912
2606
  if (reason == android.app.ApplicationExitInfo.REASON_USER_REQUESTED) {
2913
2607
  Logger.debug("App was killed by user (likely swipe-away) - checking for unclosed session")
2914
- // This will be handled by checkForUnclosedSessions()
2915
2608
  }
2916
2609
  }
2917
2610
  } catch (e: Exception) {
@@ -2930,40 +2623,29 @@ class RejourneyModuleImpl(
2930
2623
  val lastSessionStartTime = prefs.getLong("rj_session_start_time", 0)
2931
2624
 
2932
2625
  if (lastSessionId != null && lastSessionStartTime > 0) {
2933
- // Check if session was never closed (no end timestamp stored)
2934
2626
  val sessionEndTime = prefs.getLong("rj_session_end_time_$lastSessionId", 0)
2935
2627
 
2936
2628
  if (sessionEndTime == 0L) {
2937
2629
  Logger.debug("Found unclosed session: $lastSessionId (started at $lastSessionStartTime)")
2938
2630
 
2939
- // Session was never properly closed - likely app was killed
2940
- // End the session asynchronously using the upload manager
2941
2631
  backgroundScope.launch {
2942
2632
  try {
2943
- // Reconstruct upload manager state if needed
2944
2633
  uploadManager?.let { um ->
2945
- // Set the session ID temporarily to allow endSession to work
2946
2634
  val originalSessionId = um.sessionId
2947
2635
  um.sessionId = lastSessionId
2948
2636
 
2949
- // Try to end the session with the last known timestamp
2950
- // Use a timestamp slightly before now to account for the gap
2951
- val estimatedEndTime = System.currentTimeMillis() - 1000 // 1 second before now
2637
+ val estimatedEndTime = System.currentTimeMillis() - 1000
2952
2638
 
2953
2639
  Logger.debug("Ending unclosed session: $lastSessionId at $estimatedEndTime")
2954
2640
 
2955
- // Use the upload manager's endSession with override timestamp
2956
2641
  val success = um.endSession(endedAtOverride = estimatedEndTime)
2957
2642
 
2958
- // Restore original session ID
2959
2643
  um.sessionId = originalSessionId
2960
2644
 
2961
2645
  if (success) {
2962
2646
  Logger.debug("Successfully ended unclosed session: $lastSessionId")
2963
- // Clear the session markers
2964
2647
  um.clearSessionRecovery(lastSessionId)
2965
2648
 
2966
- // Update prefs to mark session as closed
2967
2649
  prefs.edit()
2968
2650
  .putLong("rj_session_end_time_$lastSessionId", estimatedEndTime)
2969
2651
  .remove("rj_current_session_id")
@@ -2978,7 +2660,6 @@ class RejourneyModuleImpl(
2978
2660
  }
2979
2661
  }
2980
2662
  } else {
2981
- // Session was properly closed, clear old markers
2982
2663
  prefs.edit()
2983
2664
  .remove("rj_current_session_id")
2984
2665
  .remove("rj_session_start_time")