@rejourneyco/react-native 1.0.7 → 1.0.9

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 (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
  3. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  4. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  5. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  6. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
  7. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  8. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
  9. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
  10. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  11. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  12. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
  13. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  14. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
  15. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  16. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  17. package/ios/Engine/DeviceRegistrar.swift +13 -3
  18. package/ios/Engine/RejourneyImpl.swift +202 -133
  19. package/ios/Recording/AnrSentinel.swift +58 -25
  20. package/ios/Recording/InteractionRecorder.swift +29 -0
  21. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  22. package/ios/Recording/ReplayOrchestrator.swift +241 -147
  23. package/ios/Recording/SegmentDispatcher.swift +155 -13
  24. package/ios/Recording/SpecialCases.swift +614 -0
  25. package/ios/Recording/StabilityMonitor.swift +42 -34
  26. package/ios/Recording/TelemetryPipeline.swift +38 -3
  27. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  28. package/ios/Recording/VisualCapture.swift +104 -28
  29. package/ios/Rejourney.mm +27 -8
  30. package/ios/Utility/ImageBlur.swift +0 -1
  31. package/lib/commonjs/index.js +32 -20
  32. package/lib/commonjs/sdk/autoTracking.js +162 -11
  33. package/lib/commonjs/sdk/constants.js +2 -2
  34. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  35. package/lib/commonjs/sdk/utils.js +1 -1
  36. package/lib/module/index.js +32 -20
  37. package/lib/module/sdk/autoTracking.js +162 -11
  38. package/lib/module/sdk/constants.js +2 -2
  39. package/lib/module/sdk/networkInterceptor.js +84 -4
  40. package/lib/module/sdk/utils.js +1 -1
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  43. package/lib/typescript/sdk/constants.d.ts +2 -2
  44. package/lib/typescript/types/index.d.ts +15 -8
  45. package/package.json +4 -4
  46. package/src/NativeRejourney.ts +8 -5
  47. package/src/index.ts +46 -29
  48. package/src/sdk/autoTracking.ts +176 -11
  49. package/src/sdk/constants.ts +2 -2
  50. package/src/sdk/networkInterceptor.ts +110 -1
  51. package/src/sdk/utils.ts +1 -1
  52. package/src/types/index.ts +16 -9
package/README.md CHANGED
@@ -26,4 +26,4 @@ Full integration guides and API reference: https://rejourney.co/docs/reactnative
26
26
 
27
27
  ## License
28
28
 
29
- Licensed under Apache 2.0
29
+ Licensed under Apache 2.0
@@ -45,6 +45,7 @@ import com.rejourney.platform.TaskRemovedListener
45
45
  import com.rejourney.recording.*
46
46
  import kotlinx.coroutines.*
47
47
  import java.security.MessageDigest
48
+ import java.util.concurrent.atomic.AtomicBoolean
48
49
  import java.util.concurrent.locks.ReentrantLock
49
50
  import kotlin.concurrent.withLock
50
51
 
@@ -68,9 +69,11 @@ class RejourneyModuleImpl(
68
69
  var sdkVersion = "1.0.1"
69
70
 
70
71
  private const val SESSION_TIMEOUT_MS = 60_000L // 60 seconds
72
+ private const val SESSION_ROLLOVER_GRACE_MS = 2_000L
71
73
 
72
74
  private const val PREFS_NAME = "com.rejourney.prefs"
73
75
  private const val KEY_USER_IDENTITY = "user_identity"
76
+ private const val KEY_ANONYMOUS_ID = "anonymous_id"
74
77
  }
75
78
 
76
79
  // State machine
@@ -143,8 +146,14 @@ class RejourneyModuleImpl(
143
146
  registerActivityLifecycleCallbacks()
144
147
  registerProcessLifecycleObserver()
145
148
 
146
- // Transmit any stored crash reports
147
- StabilityMonitor.getInstance(reactContext).transmitStoredReport()
149
+ // Recover any session interrupted by a previous crash.
150
+ // Transmit stored fault reports only after recovery restores auth context.
151
+ ReplayOrchestrator.getInstance(reactContext).recoverInterruptedReplay { recoveredId ->
152
+ if (recoveredId != null) {
153
+ DiagnosticLog.notice("[Rejourney] Recovered crashed session: $recoveredId")
154
+ }
155
+ StabilityMonitor.getInstance(reactContext).transmitStoredReport()
156
+ }
148
157
 
149
158
  // Android-specific: OEM detection and task removed handling
150
159
  setupOEMSpecificHandling()
@@ -252,16 +261,52 @@ class RejourneyModuleImpl(
252
261
 
253
262
  DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '$oldSessionId' and creating new one")
254
263
 
264
+ val restartStarted = AtomicBoolean(false)
265
+ val triggerRestart: (String) -> Unit = { source ->
266
+ if (restartStarted.compareAndSet(false, true)) {
267
+ DiagnosticLog.notice("[Rejourney] Session rollover trigger source=$source, oldSession=$oldSessionId")
268
+ mainHandler.post { startNewSessionAfterTimeout() }
269
+ }
270
+ }
271
+
272
+ mainHandler.postDelayed({
273
+ if (!restartStarted.get()) {
274
+ DiagnosticLog.caution("[Rejourney] Session rollover grace timeout reached (${SESSION_ROLLOVER_GRACE_MS}ms), forcing new session start")
275
+ }
276
+ triggerRestart("grace_timeout")
277
+ }, SESSION_ROLLOVER_GRACE_MS)
278
+
255
279
  backgroundScope.launch {
256
- ReplayOrchestrator.shared?.endReplay { success, uploaded ->
280
+ val orchestrator = ReplayOrchestrator.shared
281
+ if (orchestrator == null) {
282
+ triggerRestart("orchestrator_missing")
283
+ return@launch
284
+ }
285
+
286
+ orchestrator.endReplayWithReason("background_timeout") { success, uploaded ->
257
287
  DiagnosticLog.notice("[Rejourney] Old session ended (success: $success, uploaded: $uploaded)")
258
- mainHandler.post { startNewSessionAfterTimeout() }
288
+ triggerRestart("end_replay_callback")
259
289
  }
260
290
  }
261
291
  } else {
262
- // Resume existing session
263
- state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
264
- DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
292
+ val orchestratorSessionId = ReplayOrchestrator.shared?.replayId
293
+ if (orchestratorSessionId.isNullOrEmpty()) {
294
+ // The old session can end while app is backgrounded (e.g. duration limit).
295
+ // Do not resume a dead session; start a fresh one.
296
+ state = SessionState.Idle
297
+ DiagnosticLog.notice("[Rejourney] Session ended while backgrounded, starting fresh session on foreground")
298
+ mainHandler.post { startNewSessionAfterTimeout() }
299
+ return
300
+ }
301
+
302
+ if (orchestratorSessionId != currentState.sessionId) {
303
+ state = SessionState.Active(orchestratorSessionId, System.currentTimeMillis())
304
+ DiagnosticLog.notice("[Rejourney] ▶️ Foreground reconciled to active session '$orchestratorSessionId' (was '${currentState.sessionId}')")
305
+ } else {
306
+ // Resume existing session
307
+ state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
308
+ DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
309
+ }
265
310
 
266
311
  // Record foreground event
267
312
  TelemetryPipeline.shared?.recordAppForeground(backgroundDuration)
@@ -494,6 +539,7 @@ class RejourneyModuleImpl(
494
539
  state = SessionState.Idle
495
540
  }
496
541
 
542
+
497
543
  // Android-specific: Stop SessionLifecycleService
498
544
  stopSessionLifecycleService()
499
545
 
@@ -502,7 +548,7 @@ class RejourneyModuleImpl(
502
548
  return@post
503
549
  }
504
550
 
505
- ReplayOrchestrator.shared?.endReplay { success, uploaded ->
551
+ ReplayOrchestrator.shared?.endReplayWithReason("user_initiated") { success, uploaded ->
506
552
  DiagnosticLog.replayEnded(targetSid)
507
553
 
508
554
  promise.resolve(Arguments.createMap().apply {
@@ -554,6 +600,30 @@ class RejourneyModuleImpl(
554
600
  promise.resolve(currentUserIdentity)
555
601
  }
556
602
 
603
+ fun setAnonymousId(anonymousId: String, promise: Promise) {
604
+ try {
605
+ val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
606
+ if (anonymousId.isBlank()) {
607
+ prefs.edit().remove(KEY_ANONYMOUS_ID).apply()
608
+ } else {
609
+ prefs.edit().putString(KEY_ANONYMOUS_ID, anonymousId).apply()
610
+ }
611
+ } catch (e: Exception) {
612
+ DiagnosticLog.fault("[Rejourney] Failed to persist anonymous ID: ${e.message}")
613
+ }
614
+
615
+ promise.resolve(createResultMap(true))
616
+ }
617
+
618
+ fun getAnonymousId(promise: Promise) {
619
+ try {
620
+ val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
621
+ promise.resolve(prefs.getString(KEY_ANONYMOUS_ID, null))
622
+ } catch (_: Exception) {
623
+ promise.resolve(null)
624
+ }
625
+ }
626
+
557
627
  fun logEvent(eventType: String, details: ReadableMap, promise: Promise) {
558
628
  // Handle network_request events specially
559
629
  if (eventType == "network_request") {
@@ -589,6 +659,17 @@ class RejourneyModuleImpl(
589
659
  return
590
660
  }
591
661
 
662
+ // Handle console log events - preserve type:"log" with level and message
663
+ // so the dashboard replay can display them in the console terminal
664
+ if (eventType == "log") {
665
+ val detailsMap = details.toHashMap()
666
+ val level = detailsMap["level"]?.toString() ?: "log"
667
+ val message = detailsMap["message"]?.toString() ?: ""
668
+ TelemetryPipeline.shared?.recordConsoleLogEvent(level, message)
669
+ promise.resolve(createResultMap(true))
670
+ return
671
+ }
672
+
592
673
  // All other events go through custom event recording
593
674
  val payload = try {
594
675
  val json = org.json.JSONObject(details.toHashMap()).toString()
@@ -709,26 +790,28 @@ class RejourneyModuleImpl(
709
790
  fun getSDKMetrics(promise: Promise) {
710
791
  val dispatcher = SegmentDispatcher.shared
711
792
  val pipeline = TelemetryPipeline.shared
793
+ val telemetry = dispatcher.sdkTelemetrySnapshot(pipeline?.getQueueDepth() ?: 0)
794
+
795
+ fun toIntValue(key: String): Int = (telemetry[key] as? Number)?.toInt() ?: 0
796
+ fun toDoubleValue(key: String, fallback: Double = 0.0): Double = (telemetry[key] as? Number)?.toDouble() ?: fallback
797
+ fun toLongValue(key: String): Long? = (telemetry[key] as? Number)?.toLong()
712
798
 
713
799
  promise.resolve(Arguments.createMap().apply {
714
- putInt("uploadSuccessCount", dispatcher.uploadSuccessCount)
715
- putInt("uploadFailureCount", dispatcher.uploadFailureCount)
716
- putInt("retryAttemptCount", 0)
717
- putInt("circuitBreakerOpenCount", dispatcher.circuitBreakerOpenCount)
718
- putInt("memoryEvictionCount", 0)
719
- putInt("offlinePersistCount", 0)
720
- putInt("sessionStartCount", if (ReplayOrchestrator.shared?.replayId != null) 1 else 0)
721
- putInt("crashCount", 0)
722
-
723
- val total = dispatcher.uploadSuccessCount + dispatcher.uploadFailureCount
724
- putDouble("uploadSuccessRate", if (total > 0) dispatcher.uploadSuccessCount.toDouble() / total else 1.0)
725
-
726
- putDouble("avgUploadDurationMs", 0.0)
727
- putInt("currentQueueDepth", pipeline?.getQueueDepth() ?: 0)
728
- putNull("lastUploadTime")
729
- putNull("lastRetryTime")
730
- putDouble("totalBytesUploaded", dispatcher.totalBytesUploaded.toDouble())
731
- putInt("totalBytesEvicted", 0)
800
+ putInt("uploadSuccessCount", toIntValue("uploadSuccessCount"))
801
+ putInt("uploadFailureCount", toIntValue("uploadFailureCount"))
802
+ putInt("retryAttemptCount", toIntValue("retryAttemptCount"))
803
+ putInt("circuitBreakerOpenCount", toIntValue("circuitBreakerOpenCount"))
804
+ putInt("memoryEvictionCount", toIntValue("memoryEvictionCount"))
805
+ putInt("offlinePersistCount", toIntValue("offlinePersistCount"))
806
+ putInt("sessionStartCount", toIntValue("sessionStartCount"))
807
+ putInt("crashCount", toIntValue("crashCount"))
808
+ putDouble("uploadSuccessRate", toDoubleValue("uploadSuccessRate", 1.0))
809
+ putDouble("avgUploadDurationMs", toDoubleValue("avgUploadDurationMs", 0.0))
810
+ putInt("currentQueueDepth", toIntValue("currentQueueDepth"))
811
+ toLongValue("lastUploadTime")?.let { putDouble("lastUploadTime", it.toDouble()) } ?: putNull("lastUploadTime")
812
+ toLongValue("lastRetryTime")?.let { putDouble("lastRetryTime", it.toDouble()) } ?: putNull("lastRetryTime")
813
+ putDouble("totalBytesUploaded", toDoubleValue("totalBytesUploaded", 0.0))
814
+ putDouble("totalBytesEvicted", toDoubleValue("totalBytesEvicted", 0.0))
732
815
  })
733
816
  }
734
817
 
@@ -64,6 +64,7 @@ class DeviceRegistrar private constructor(private val context: Context) {
64
64
  // Private State
65
65
  private val prefsKey = "com.rejourney.device"
66
66
  private val fingerprintKey = "device_fingerprint"
67
+ private val fallbackIdKey = "device_fallback_id"
67
68
  private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
68
69
 
69
70
  private val httpClient: OkHttpClient = OkHttpClient.Builder()
@@ -169,7 +170,6 @@ class DeviceRegistrar private constructor(private val context: Context) {
169
170
  var composite = packageName
170
171
  composite += Build.MODEL
171
172
  composite += Build.MANUFACTURER
172
- composite += Build.VERSION.RELEASE
173
173
  composite += getAndroidId()
174
174
 
175
175
  return sha256(composite)
@@ -177,12 +177,27 @@ class DeviceRegistrar private constructor(private val context: Context) {
177
177
 
178
178
  private fun getAndroidId(): String {
179
179
  return try {
180
- Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: java.util.UUID.randomUUID().toString()
180
+ Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
181
+ ?: stableDeviceFallback()
181
182
  } catch (e: Exception) {
182
- java.util.UUID.randomUUID().toString()
183
+ stableDeviceFallback()
183
184
  }
184
185
  }
185
186
 
187
+ /**
188
+ * Returns a SharedPreferences-persisted UUID so the fingerprint stays stable
189
+ * even when ANDROID_ID is unavailable (restricted profiles, some OEM devices).
190
+ */
191
+ private fun stableDeviceFallback(): String {
192
+ val prefs = context.getSharedPreferences(prefsKey, Context.MODE_PRIVATE)
193
+ val existing = prefs.getString(fallbackIdKey, null)
194
+ if (existing != null) return existing
195
+
196
+ val fresh = java.util.UUID.randomUUID().toString()
197
+ prefs.edit().putString(fallbackIdKey, fresh).apply()
198
+ return fresh
199
+ }
200
+
186
201
  // Server Communication
187
202
 
188
203
  private suspend fun fetchServerCredential(
@@ -30,6 +30,7 @@ import androidx.lifecycle.LifecycleOwner
30
30
  import androidx.lifecycle.ProcessLifecycleOwner
31
31
  import com.rejourney.recording.*
32
32
  import java.security.MessageDigest
33
+ import java.util.concurrent.atomic.AtomicBoolean
33
34
  import java.util.concurrent.locks.ReentrantLock
34
35
  import kotlin.concurrent.withLock
35
36
 
@@ -45,26 +46,26 @@ sealed class SessionState {
45
46
 
46
47
  /**
47
48
  * Main SDK implementation aligned with iOS RejourneyImpl.swift
48
- *
49
+ *
49
50
  * This class provides the core SDK functionality for native Android usage.
50
51
  * For React Native, use RejourneyModuleImpl instead.
51
52
  */
52
- class RejourneyImpl private constructor(private val context: Context) :
53
+ class RejourneyImpl private constructor(private val context: Context) :
53
54
  Application.ActivityLifecycleCallbacks, DefaultLifecycleObserver {
54
55
 
55
56
  companion object {
56
57
  @Volatile
57
58
  private var instance: RejourneyImpl? = null
58
-
59
+
59
60
  fun getInstance(context: Context): RejourneyImpl {
60
61
  return instance ?: synchronized(this) {
61
62
  instance ?: RejourneyImpl(context.applicationContext).also { instance = it }
62
63
  }
63
64
  }
64
-
65
+
65
66
  val shared: RejourneyImpl?
66
67
  get() = instance
67
-
68
+
68
69
  var sdkVersion = "1.0.1"
69
70
  }
70
71
 
@@ -81,14 +82,23 @@ class RejourneyImpl private constructor(private val context: Context) :
81
82
 
82
83
  // Session timeout threshold (60 seconds)
83
84
  private val sessionTimeoutMs = 60_000L
85
+ private val sessionRolloverGraceMs = 2_000L
84
86
 
85
87
  private val mainHandler = Handler(Looper.getMainLooper())
86
-
88
+
87
89
  @Volatile
88
90
  private var isInitialized = false
89
91
 
90
92
  init {
91
93
  setupLifecycleListeners()
94
+
95
+ // Recover sessions interrupted by a previous crash first, then send stored faults.
96
+ ReplayOrchestrator.getInstance(context).recoverInterruptedReplay { recoveredId ->
97
+ if (recoveredId != null) {
98
+ DiagnosticLog.notice("[Rejourney] Recovered crashed session: $recoveredId")
99
+ }
100
+ StabilityMonitor.getInstance(context).transmitStoredReport()
101
+ }
92
102
  }
93
103
 
94
104
  private fun setupLifecycleListeners() {
@@ -97,10 +107,10 @@ class RejourneyImpl private constructor(private val context: Context) :
97
107
  mainHandler.post {
98
108
  ProcessLifecycleOwner.get().lifecycle.addObserver(this)
99
109
  }
100
-
110
+
101
111
  // Register activity callbacks
102
112
  (context.applicationContext as? Application)?.registerActivityLifecycleCallbacks(this)
103
-
113
+
104
114
  } catch (e: Exception) {
105
115
  DiagnosticLog.fault("[Rejourney] Failed to setup lifecycle listeners: ${e.message}")
106
116
  }
@@ -123,7 +133,7 @@ class RejourneyImpl private constructor(private val context: Context) :
123
133
  state = SessionState.Paused(currentState.sessionId, currentState.startTimeMs)
124
134
  backgroundEntryTimeMs = System.currentTimeMillis()
125
135
  DiagnosticLog.notice("[Rejourney] ⏸️ Session '${currentState.sessionId}' paused (app backgrounded)")
126
-
136
+
127
137
  TelemetryPipeline.shared?.dispatchNow()
128
138
  SegmentDispatcher.shared.shipPending()
129
139
  }
@@ -158,16 +168,49 @@ class RejourneyImpl private constructor(private val context: Context) :
158
168
 
159
169
  DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '$oldSessionId' and creating new one")
160
170
 
161
- Thread {
162
- ReplayOrchestrator.shared?.endReplay { success, uploaded ->
163
- DiagnosticLog.notice("[Rejourney] Old session ended (success: $success, uploaded: $uploaded)")
171
+ val restartStarted = AtomicBoolean(false)
172
+ val triggerRestart: (String) -> Unit = { source ->
173
+ if (restartStarted.compareAndSet(false, true)) {
174
+ DiagnosticLog.notice("[Rejourney] Session rollover trigger source=$source, oldSession=$oldSessionId")
164
175
  mainHandler.post { startNewSessionAfterTimeout() }
165
176
  }
177
+ }
178
+
179
+ mainHandler.postDelayed({
180
+ if (!restartStarted.get()) {
181
+ DiagnosticLog.caution("[Rejourney] Session rollover grace timeout reached (${sessionRolloverGraceMs}ms), forcing new session start")
182
+ }
183
+ triggerRestart("grace_timeout")
184
+ }, sessionRolloverGraceMs)
185
+
186
+ Thread {
187
+ val orchestrator = ReplayOrchestrator.shared
188
+ if (orchestrator == null) {
189
+ triggerRestart("orchestrator_missing")
190
+ } else {
191
+ orchestrator.endReplayWithReason("background_timeout") { success, uploaded ->
192
+ DiagnosticLog.notice("[Rejourney] Old session ended (success: $success, uploaded: $uploaded)")
193
+ triggerRestart("end_replay_callback")
194
+ }
195
+ }
166
196
  }.start()
167
197
  } else {
168
- // Resume existing session
169
- state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
170
- DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
198
+ val orchestratorSessionId = ReplayOrchestrator.shared?.replayId
199
+ if (orchestratorSessionId.isNullOrEmpty()) {
200
+ state = SessionState.Idle
201
+ DiagnosticLog.notice("[Rejourney] Session ended while backgrounded, starting fresh session on foreground")
202
+ mainHandler.post { startNewSessionAfterTimeout() }
203
+ return
204
+ }
205
+
206
+ if (orchestratorSessionId != currentState.sessionId) {
207
+ state = SessionState.Active(orchestratorSessionId, System.currentTimeMillis())
208
+ DiagnosticLog.notice("[Rejourney] ▶️ Foreground reconciled to active session '$orchestratorSessionId' (was '${currentState.sessionId}')")
209
+ } else {
210
+ // Resume existing session
211
+ state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
212
+ DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
213
+ }
171
214
 
172
215
  TelemetryPipeline.shared?.recordAppForeground(backgroundDuration)
173
216
  StabilityMonitor.shared?.transmitStoredReport()
@@ -323,7 +366,7 @@ class RejourneyImpl private constructor(private val context: Context) :
323
366
  return@post
324
367
  }
325
368
 
326
- ReplayOrchestrator.shared?.endReplay { success, uploaded ->
369
+ ReplayOrchestrator.shared?.endReplayWithReason("user_initiated") { success, uploaded ->
327
370
  DiagnosticLog.replayEnded(targetSid)
328
371
  callback?.invoke(success, targetSid, uploaded)
329
372
  }
@@ -385,6 +428,15 @@ class RejourneyImpl private constructor(private val context: Context) :
385
428
  return
386
429
  }
387
430
 
431
+ // Handle console log events - preserve type:"log" with level and message
432
+ // so the dashboard replay can display them in the console terminal
433
+ if (eventType == "log") {
434
+ val level = details?.get("level")?.toString() ?: "log"
435
+ val message = details?.get("message")?.toString() ?: ""
436
+ TelemetryPipeline.shared?.recordConsoleLogEvent(level, message)
437
+ return
438
+ }
439
+
388
440
  val payload = try {
389
441
  org.json.JSONObject(details ?: emptyMap<String, Any>()).toString()
390
442
  } catch (e: Exception) {
@@ -499,7 +551,7 @@ class RejourneyImpl private constructor(private val context: Context) :
499
551
  else -> {}
500
552
  }
501
553
  }
502
-
554
+
503
555
  try {
504
556
  (context.applicationContext as? Application)?.unregisterActivityLifecycleCallbacks(this)
505
557
  mainHandler.post {
@@ -55,6 +55,11 @@ class AnrSentinel private constructor() {
55
55
 
56
56
  fun activate() {
57
57
  if (isActive.getAndSet(true)) return
58
+
59
+ // Reset watchdog state on each activation to avoid stale timings from
60
+ // previous app background periods.
61
+ lastResponseTime.set(System.currentTimeMillis())
62
+ pongSequence.set(pingSequence.get())
58
63
 
59
64
  startWatchdog()
60
65
  }
@@ -87,7 +92,7 @@ class AnrSentinel private constructor() {
87
92
  val elapsed = System.currentTimeMillis() - lastResponseTime.get()
88
93
  val missedPongs = pingSequence.get() - pongSequence.get()
89
94
 
90
- if (elapsed > anrThresholdMs && missedPongs > 0) {
95
+ if (elapsed >= anrThresholdMs && missedPongs > 0) {
91
96
  captureAnr(elapsed)
92
97
 
93
98
  // Reset to avoid duplicate reports
@@ -113,12 +118,32 @@ class AnrSentinel private constructor() {
113
118
  "${element.className}.${element.methodName}(${element.fileName}:${element.lineNumber})"
114
119
  }
115
120
 
116
- ReplayOrchestrator.shared?.incrementFaultTally()
121
+ ReplayOrchestrator.shared?.incrementStalledTally()
117
122
 
118
123
  // Route ANR through TelemetryPipeline so it arrives in the events
119
124
  // batch and the backend ingest worker can insert it into the anrs table
120
125
  val stackStr = frames.joinToString("\n")
121
126
  TelemetryPipeline.shared?.recordAnrEvent(durationMs, stackStr)
127
+
128
+ // Persist ANR incident and send through /api/ingest/fault so ANRs survive
129
+ // process termination/background upload loss, similar to crash recovery.
130
+ val sessionId = StabilityMonitor.shared?.currentSessionId
131
+ ?: ReplayOrchestrator.shared?.replayId
132
+ ?: "unknown"
133
+ val incident = IncidentRecord(
134
+ sessionId = sessionId,
135
+ timestampMs = System.currentTimeMillis(),
136
+ category = "anr",
137
+ identifier = "MainThreadFrozen",
138
+ detail = "Main thread unresponsive for ${durationMs}ms",
139
+ frames = frames,
140
+ context = mapOf(
141
+ "durationMs" to durationMs.toString(),
142
+ "threadState" to "blocked"
143
+ )
144
+ )
145
+ StabilityMonitor.shared?.persistIncidentSync(incident)
146
+ StabilityMonitor.shared?.transmitStoredReport()
122
147
 
123
148
  DiagnosticLog.fault("ANR detected: ${durationMs}ms hang")
124
149
 
@@ -29,6 +29,7 @@ import android.widget.EditText
29
29
  import android.widget.ImageButton
30
30
  import java.lang.ref.WeakReference
31
31
  import java.util.concurrent.CopyOnWriteArrayList
32
+ import java.util.concurrent.atomic.AtomicLong
32
33
  import kotlin.math.abs
33
34
  import kotlin.math.sqrt
34
35
 
@@ -59,6 +60,7 @@ class InteractionRecorder private constructor(private val context: Context) {
59
60
  private val inputObservers = CopyOnWriteArrayList<WeakReference<EditText>>()
60
61
  private val navigationStack = mutableListOf<String>()
61
62
  private val coalesceWindow: Long = 300 // ms
63
+ private val lastInteractionTimestampMs = AtomicLong(0L)
62
64
 
63
65
  internal var currentActivity: WeakReference<Activity>? = null
64
66
 
@@ -86,8 +88,11 @@ class InteractionRecorder private constructor(private val context: Context) {
86
88
  gestureAggregator = null
87
89
  inputObservers.clear()
88
90
  navigationStack.clear()
91
+ lastInteractionTimestampMs.set(0L)
89
92
  }
90
93
 
94
+ fun latestInteractionTimestampMs(): Long = lastInteractionTimestampMs.get()
95
+
91
96
  fun observeTextField(field: EditText) {
92
97
  if (inputObservers.any { it.get() === field }) return
93
98
  inputObservers.add(WeakReference(field))
@@ -122,13 +127,29 @@ class InteractionRecorder private constructor(private val context: Context) {
122
127
  window.callback = object : Window.Callback by original {
123
128
  override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
124
129
  if (event != null) {
130
+ markInteractionNow()
125
131
  agg.processTouchEvent(event)
132
+
133
+ // Notify SpecialCases about touch phases for touch-based
134
+ // map idle detection (Mapbox v10+ fallback).
135
+ when (event.actionMasked) {
136
+ MotionEvent.ACTION_DOWN -> {
137
+ VisualCapture.shared?.invalidateMaskCache()
138
+ SpecialCases.shared.notifyTouchBegan()
139
+ }
140
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL ->
141
+ SpecialCases.shared.notifyTouchEnded()
142
+ }
126
143
  }
127
144
  return original.dispatchTouchEvent(event)
128
145
  }
129
146
  }
130
147
  }
131
148
 
149
+ private fun markInteractionNow() {
150
+ lastInteractionTimestampMs.set(System.currentTimeMillis())
151
+ }
152
+
132
153
  private fun removeGlobalTouchListener() {
133
154
  val window = installedWindow?.get()
134
155
  if (window != null) {
@@ -450,6 +471,11 @@ private class GestureAggregator(
450
471
  }
451
472
 
452
473
  private fun resolveTarget(location: PointF): String {
474
+ // When a map view is visible, skip the expensive hit-test walk
475
+ // through the deep SurfaceView/TextureView hierarchy.
476
+ if (SpecialCases.shared.mapVisible) {
477
+ return "map"
478
+ }
453
479
  return "view_${location.x.toInt()}_${location.y.toInt()}"
454
480
  }
455
481
 
@@ -462,6 +488,10 @@ private class GestureAggregator(
462
488
  * (e.g. TextView inside a Pressable), not the clickable Pressable itself.
463
489
  */
464
490
  private fun isViewInteractive(location: PointF): Boolean {
491
+ // Skip the expensive hierarchy walk when a map is visible to
492
+ // prevent micro-stutter during pan/zoom gestures.
493
+ if (SpecialCases.shared.mapVisible) return false
494
+
465
495
  val activity = recorder.currentActivity?.get() ?: return false
466
496
  val decorView = activity.window?.decorView ?: return false
467
497
  val hit = findViewAt(decorView, location.x.toInt(), location.y.toInt()) ?: return false
@@ -0,0 +1,100 @@
1
+ package com.rejourney.recording
2
+
3
+ import okhttp3.Interceptor
4
+ import okhttp3.Response
5
+ import java.io.IOException
6
+ import java.util.UUID
7
+
8
+ /**
9
+ * Native OkHttp Interceptor for Rejourney
10
+ *
11
+ * Captures native network traffic and routes it to the Rejourney TelemetryPipeline.
12
+ * To use, add this interceptor to your native OkHttpClient:
13
+ * val client = OkHttpClient.Builder()
14
+ * .addInterceptor(RejourneyNetworkInterceptor())
15
+ * .build()
16
+ */
17
+ class RejourneyNetworkInterceptor : Interceptor {
18
+
19
+ @Throws(IOException::class)
20
+ override fun intercept(chain: Interceptor.Chain): Response {
21
+ val request = chain.request()
22
+ val host = request.url.host
23
+
24
+ // Skip Rejourney's own API traffic to avoid ingestion duplication (mirrors iOS RejourneyURLProtocol)
25
+ if (host.contains("api.rejourney.co") || host.contains("rejourney")) {
26
+ return chain.proceed(request)
27
+ }
28
+
29
+ val startMs = System.currentTimeMillis()
30
+
31
+ var response: Response? = null
32
+ var error: Exception? = null
33
+
34
+ try {
35
+ response = chain.proceed(request)
36
+ } catch (e: Exception) {
37
+ error = e
38
+ throw e
39
+ } finally {
40
+ try {
41
+ val endMs = System.currentTimeMillis()
42
+ val duration = endMs - startMs
43
+
44
+ val isSuccess = response?.isSuccessful == true
45
+ val statusCode = response?.code ?: 0
46
+
47
+ val urlStr = request.url.toString()
48
+ val pathStr = request.url.encodedPath
49
+
50
+ var maxUrlStr = urlStr
51
+ if (maxUrlStr.length > 300) {
52
+ maxUrlStr = maxUrlStr.substring(0, 300)
53
+ }
54
+
55
+ val reqSize = request.body?.contentLength()?.takeIf { it >= 0 }?.toInt() ?: 0
56
+ val resSize = response?.body?.contentLength()?.takeIf { it >= 0 }?.toInt() ?: 0
57
+
58
+ val event = mutableMapOf<String, Any>(
59
+ "requestId" to "n_${UUID.randomUUID()}",
60
+ "method" to request.method,
61
+ "url" to maxUrlStr,
62
+ "urlPath" to pathStr,
63
+ "urlHost" to request.url.host,
64
+ "statusCode" to statusCode,
65
+ "duration" to duration,
66
+ "startTimestamp" to startMs,
67
+ "endTimestamp" to endMs,
68
+ "success" to isSuccess
69
+ )
70
+
71
+ if (reqSize > 0) {
72
+ event["requestBodySize"] = reqSize
73
+ }
74
+
75
+ request.body?.contentType()?.let {
76
+ event["requestContentType"] = it.toString()
77
+ }
78
+
79
+ if (resSize > 0) {
80
+ event["responseBodySize"] = resSize
81
+ }
82
+
83
+ response?.body?.contentType()?.let {
84
+ event["responseContentType"] = it.toString()
85
+ }
86
+
87
+ error?.let {
88
+ event["errorMessage"] = it.message ?: "Network error"
89
+ }
90
+
91
+ TelemetryPipeline.shared?.recordNetworkEvent(event)
92
+
93
+ } catch (ignore: Exception) {
94
+ // Ignore to avoid breaking the application network call
95
+ }
96
+ }
97
+
98
+ return response!!
99
+ }
100
+ }