@rejourneyco/react-native 1.0.2 → 1.0.4

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