@rejourneyco/react-native 1.0.2 → 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.
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +38 -363
- package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +11 -113
- package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +1 -15
- package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +1 -61
- package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +3 -1
- package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +1 -22
- package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +3 -26
- package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +0 -2
- package/android/src/main/java/com/rejourney/network/UploadManager.kt +7 -93
- package/android/src/main/java/com/rejourney/network/UploadWorker.kt +5 -41
- package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +2 -58
- package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +4 -4
- package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +36 -7
- package/ios/Capture/RJViewHierarchyScanner.m +68 -51
- package/ios/Core/RJLifecycleManager.m +0 -14
- package/ios/Core/Rejourney.mm +24 -37
- package/ios/Network/RJDeviceAuthManager.m +0 -2
- package/ios/Network/RJUploadManager.h +8 -0
- package/ios/Network/RJUploadManager.m +45 -0
- package/ios/Privacy/RJPrivacyMask.m +5 -31
- package/ios/Rejourney.h +0 -14
- package/ios/Touch/RJTouchInterceptor.m +21 -15
- package/ios/Utils/RJEventBuffer.m +57 -69
- package/ios/Utils/RJWindowUtils.m +87 -86
- package/lib/commonjs/index.js +42 -30
- package/lib/commonjs/sdk/autoTracking.js +0 -3
- package/lib/commonjs/sdk/networkInterceptor.js +0 -11
- package/lib/commonjs/sdk/utils.js +73 -14
- package/lib/module/index.js +42 -30
- package/lib/module/sdk/autoTracking.js +0 -3
- package/lib/module/sdk/networkInterceptor.js +0 -11
- package/lib/module/sdk/utils.js +73 -14
- package/lib/typescript/sdk/utils.d.ts +31 -1
- package/package.json +16 -4
- package/src/index.ts +40 -19
- package/src/sdk/autoTracking.ts +0 -2
- package/src/sdk/constants.ts +13 -13
- package/src/sdk/networkInterceptor.ts +0 -9
- 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
|
|
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
|
|
87
|
-
private const val AUTH_RETRY_MAX_DELAY_MS = 60000L
|
|
88
|
-
private const val AUTH_BACKGROUND_RETRY_DELAY_MS = 300000L
|
|
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) {
|
|
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()
|
|
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) {
|
|
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
|
-
|
|
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
|
|
1113
|
-
remoteRecordingEnabled =
|
|
1036
|
+
val shouldRecordVideo = recordingEnabledByConfig && sessionSampled
|
|
1037
|
+
remoteRecordingEnabled = shouldRecordVideo
|
|
1114
1038
|
|
|
1115
|
-
if (!
|
|
1039
|
+
if (!shouldRecordVideo && captureEngine?.isRecording == true) {
|
|
1116
1040
|
captureEngine?.stopSession()
|
|
1117
1041
|
}
|
|
1118
1042
|
|
|
1119
1043
|
if (decidedSample && recordingEnabledByConfig && !sessionSampled) {
|
|
1120
|
-
Logger.
|
|
1044
|
+
Logger.info("Session sampled out for video (${clampedRate}%) - entering Data-Only Mode (Events enabled, Video disabled)")
|
|
1121
1045
|
}
|
|
1122
1046
|
|
|
1123
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
val uploadSuccess =
|
|
1400
|
-
|
|
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:
|
|
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 =
|
|
1419
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|
|
2242
|
-
expedited = true
|
|
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)
|
|
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
|
-
}
|
|
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),
|
|
2091
|
+
"touches" to listOf(touchPoint),
|
|
2356
2092
|
"duration" to (details["duration"] ?: 0),
|
|
2357
2093
|
"targetLabel" to details["targetLabel"],
|
|
2358
|
-
"x" to x,
|
|
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,
|
|
2157
|
+
"type" to EventType.GESTURE,
|
|
2424
2158
|
"timestamp" to timestamp,
|
|
2425
2159
|
"gestureType" to "rage_tap",
|
|
2426
|
-
"touches" to listOf(touchPoint),
|
|
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
|
|
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
|
-
|
|
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")
|