@rejourneyco/react-native 1.0.8 → 1.0.10

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 +77 -3
  2. package/android/src/main/AndroidManifest.xml +6 -0
  3. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
  4. package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
  5. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
  6. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  7. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  8. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  9. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
  10. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
  11. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
  12. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  13. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
  14. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  15. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  16. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
  17. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  18. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  19. package/ios/Engine/DeviceRegistrar.swift +13 -3
  20. package/ios/Engine/RejourneyImpl.swift +204 -115
  21. package/ios/Recording/AnrSentinel.swift +58 -25
  22. package/ios/Recording/InteractionRecorder.swift +1 -0
  23. package/ios/Recording/RejourneyURLProtocol.swift +216 -0
  24. package/ios/Recording/ReplayOrchestrator.swift +207 -144
  25. package/ios/Recording/SegmentDispatcher.swift +8 -0
  26. package/ios/Recording/StabilityMonitor.swift +40 -32
  27. package/ios/Recording/TelemetryPipeline.swift +45 -2
  28. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  29. package/ios/Recording/VisualCapture.swift +79 -29
  30. package/ios/Rejourney.mm +27 -8
  31. package/ios/Utility/DataCompression.swift +2 -2
  32. package/ios/Utility/ImageBlur.swift +0 -1
  33. package/lib/commonjs/expoRouterTracking.js +137 -0
  34. package/lib/commonjs/index.js +204 -34
  35. package/lib/commonjs/sdk/autoTracking.js +262 -100
  36. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  37. package/lib/module/expoRouterTracking.js +135 -0
  38. package/lib/module/index.js +203 -28
  39. package/lib/module/sdk/autoTracking.js +260 -100
  40. package/lib/module/sdk/networkInterceptor.js +84 -4
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/expoRouterTracking.d.ts +14 -0
  43. package/lib/typescript/index.d.ts +2 -2
  44. package/lib/typescript/sdk/autoTracking.d.ts +14 -1
  45. package/lib/typescript/types/index.d.ts +56 -5
  46. package/package.json +23 -3
  47. package/src/NativeRejourney.ts +8 -5
  48. package/src/expoRouterTracking.ts +167 -0
  49. package/src/index.ts +221 -35
  50. package/src/sdk/autoTracking.ts +286 -114
  51. package/src/sdk/networkInterceptor.ts +110 -1
  52. package/src/types/index.ts +58 -6
package/README.md CHANGED
@@ -11,15 +11,89 @@ npm install @rejourneyco/react-native
11
11
  ## Quick Start
12
12
 
13
13
  ```typescript
14
- import { initRejourney, startRejourney } from '@rejourneyco/react-native';
14
+ import { Rejourney } from '@rejourneyco/react-native';
15
15
 
16
16
  // Initialize with your public key
17
- initRejourney('pk_live_xxxxxxxxxxxx');
17
+ Rejourney.init('pk_live_xxxxxxxxxxxx');
18
18
 
19
19
  // Start recording after obtaining user consent
20
- startRejourney();
20
+ Rejourney.start();
21
21
  ```
22
22
 
23
+ ## Navigation Tracking
24
+
25
+ Rejourney automatically tracks screen changes to provide context for your session replays.
26
+
27
+ ### Expo Router (Automatic)
28
+ If you use **Expo Router**, simply add this import at your root layout (`app/_layout.tsx`):
29
+ ```ts
30
+ import '@rejourneyco/react-native/expo-router';
31
+ ```
32
+
33
+ ### React Navigation
34
+ If you are using **React Navigation** (`@react-navigation/native`), use the `useNavigationTracking` hook in your root `NavigationContainer`:
35
+ ```tsx
36
+ import { Rejourney } from '@rejourneyco/react-native';
37
+ import { NavigationContainer } from '@react-navigation/native';
38
+
39
+ const navigationTracking = Rejourney.useNavigationTracking();
40
+ return <NavigationContainer {...navigationTracking}>{/*...*/}</NavigationContainer>;
41
+ ```
42
+
43
+ ### Custom Screen Names
44
+ If you want to manually specify screen names or use a different library:
45
+
46
+ #### For Expo Router users:
47
+ Disable automatic tracking in your initialization:
48
+ ```ts
49
+ Rejourney.init('pk_live_xxxxxxxxxxxx', {
50
+ autoTrackExpoRouter: false
51
+ });
52
+ ```
53
+
54
+ #### Manual tracking call:
55
+ Notify Rejourney of screen changes using `trackScreen`:
56
+ ```ts
57
+ import { Rejourney } from '@rejourneyco/react-native';
58
+
59
+ Rejourney.trackScreen('Custom Screen Name');
60
+ ```
61
+
62
+ > [!NOTE]
63
+ > `expo-router` is an **optional peer dependency**. The SDK is carefully architectural to avoid requiring `expo-router` in the main bundle. This prevents Metro from attempting to resolve it at build time in projects where it's not installed, which would otherwise cause a "Requiring unknown module" crash.
64
+
65
+ ## Custom Events & Metadata
66
+
67
+ You can track custom events and assign metadata to sessions to filter and segment them later.
68
+
69
+ ```typescript
70
+ import { Rejourney } from '@rejourneyco/react-native';
71
+
72
+ // Log custom events
73
+ Rejourney.logEvent('button_clicked', { buttonName: 'signup' });
74
+
75
+ // Add custom session metadata
76
+ Rejourney.setMetadata('plan', 'premium');
77
+ Rejourney.setMetadata({
78
+ role: 'tester',
79
+ ab_test_group: 'A'
80
+ });
81
+ ```
82
+
83
+ ## API Reference & Compatibility
84
+
85
+ Rejourney supports both a standardized `Rejourney.` namespace and standalone function exports (AKA calls). Both are fully supported.
86
+
87
+ | Standardized Method | Standalone Alias (AKA) |
88
+ | --- | --- |
89
+ | `Rejourney.init()` | `initRejourney()` |
90
+ | `Rejourney.start()` | `startRejourney()` |
91
+ | `Rejourney.stop()` | `stopRejourney()` |
92
+ | `Rejourney.useNavigationTracking()` | `useNavigationTracking()` |
93
+
94
+ > [!TIP]
95
+ > We recommend using the `Rejourney.` prefix for better discoverability and a cleaner import surface.
96
+
23
97
  ## Documentation
24
98
 
25
99
  Full integration guides and API reference: https://rejourney.co/docs/reactnative/overview
@@ -4,6 +4,12 @@
4
4
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5
5
 
6
6
  <application>
7
+ <!-- Run before bridge init so OkHttpClientProvider uses our factory when NetworkingModule is created -->
8
+ <provider
9
+ android:name=".RejourneyOkHttpInitProvider"
10
+ android:authorities="${applicationId}.rejourney.okhttpinit"
11
+ android:exported="false"
12
+ android:initOrder="100" />
7
13
  <!-- Service to detect app termination when swiped away from recent apps -->
8
14
  <!-- stopWithTask="false" is CRITICAL - allows onTaskRemoved() to fire when app is killed -->
9
15
  <service
@@ -37,14 +37,21 @@ import androidx.lifecycle.DefaultLifecycleObserver
37
37
  import androidx.lifecycle.LifecycleOwner
38
38
  import androidx.lifecycle.ProcessLifecycleOwner
39
39
  import com.facebook.react.bridge.*
40
+ import com.facebook.react.modules.network.CustomClientBuilder
41
+ import com.facebook.react.modules.network.OkHttpClientFactory
42
+ import com.facebook.react.modules.network.OkHttpClientProvider
43
+ import com.facebook.react.modules.network.NetworkingModule
44
+ import okhttp3.OkHttpClient
40
45
  import com.rejourney.engine.DeviceRegistrar
41
46
  import com.rejourney.engine.DiagnosticLog
47
+
42
48
  import com.rejourney.platform.OEMDetector
43
49
  import com.rejourney.platform.SessionLifecycleService
44
50
  import com.rejourney.platform.TaskRemovedListener
45
51
  import com.rejourney.recording.*
46
52
  import kotlinx.coroutines.*
47
53
  import java.security.MessageDigest
54
+ import java.util.concurrent.atomic.AtomicBoolean
48
55
  import java.util.concurrent.locks.ReentrantLock
49
56
  import kotlin.concurrent.withLock
50
57
 
@@ -68,9 +75,11 @@ class RejourneyModuleImpl(
68
75
  var sdkVersion = "1.0.1"
69
76
 
70
77
  private const val SESSION_TIMEOUT_MS = 60_000L // 60 seconds
78
+ private const val SESSION_ROLLOVER_GRACE_MS = 2_000L
71
79
 
72
80
  private const val PREFS_NAME = "com.rejourney.prefs"
73
81
  private const val KEY_USER_IDENTITY = "user_identity"
82
+ private const val KEY_ANONYMOUS_ID = "anonymous_id"
74
83
  }
75
84
 
76
85
  // State machine
@@ -143,8 +152,49 @@ class RejourneyModuleImpl(
143
152
  registerActivityLifecycleCallbacks()
144
153
  registerProcessLifecycleObserver()
145
154
 
146
- // Transmit any stored crash reports
147
- StabilityMonitor.getInstance(reactContext).transmitStoredReport()
155
+ // Recover any session interrupted by a previous crash.
156
+ // Transmit stored fault reports only after recovery restores auth context.
157
+ ReplayOrchestrator.getInstance(reactContext).recoverInterruptedReplay { recoveredId ->
158
+ if (recoveredId != null) {
159
+ DiagnosticLog.notice("[Rejourney] Recovered crashed session: $recoveredId")
160
+ }
161
+ StabilityMonitor.getInstance(reactContext).transmitStoredReport()
162
+ }
163
+
164
+ // Register OkHttp interceptor so native/RN fetch() and XHR go through Rejourney.
165
+ // Use both mechanisms so we catch requests regardless of init order:
166
+ // 1) OkHttpClientProvider factory + clear cache (correct field: sClient)
167
+ // 2) NetworkingModule.setCustomClientBuilder — applied on every request's client
168
+ try {
169
+ OkHttpClientProvider.setOkHttpClientFactory(OkHttpClientFactory {
170
+ OkHttpClientProvider.createClientBuilder()
171
+ .addInterceptor(RejourneyNetworkInterceptor())
172
+ .build()
173
+ })
174
+ // React Native caches the client in OkHttpClientProvider.sClient (not "client").
175
+ // Clear it so the next getOkHttpClient() uses our factory.
176
+ try {
177
+ val clientField = OkHttpClientProvider::class.java.getDeclaredField("sClient")
178
+ clientField.isAccessible = true
179
+ clientField.set(null, null)
180
+ } catch (_: Exception) {}
181
+ OkHttpClientProvider.getOkHttpClient()
182
+ // Ensure every request (including those already using a cached client) gets our
183
+ // interceptor. NetworkingModule builds per-request clients via mClient.newBuilder()
184
+ // and applies this builder, so this catches all native API calls.
185
+ try {
186
+ NetworkingModule.setCustomClientBuilder(object : CustomClientBuilder {
187
+ override fun apply(builder: OkHttpClient.Builder) {
188
+ builder.addInterceptor(RejourneyNetworkInterceptor())
189
+ }
190
+ })
191
+ DiagnosticLog.notice("[Rejourney] Registered OkHttp interceptor + CustomClientBuilder")
192
+ } catch (e: Exception) {
193
+ DiagnosticLog.notice("[Rejourney] OkHttp interceptor registered (CustomClientBuilder skipped: ${e.message})")
194
+ }
195
+ } catch (e: Exception) {
196
+ DiagnosticLog.fault("[Rejourney] Failed to register OkHttp interceptor factory: ${e.message}")
197
+ }
148
198
 
149
199
  // Android-specific: OEM detection and task removed handling
150
200
  setupOEMSpecificHandling()
@@ -222,6 +272,8 @@ class RejourneyModuleImpl(
222
272
  // Flush pending data
223
273
  TelemetryPipeline.shared?.dispatchNow()
224
274
  SegmentDispatcher.shared.shipPending()
275
+ // Stop the heartbeat timer to prevent event uploads while backgrounded
276
+ TelemetryPipeline.shared?.pause()
225
277
  }
226
278
  }
227
279
  }
@@ -245,6 +297,9 @@ class RejourneyModuleImpl(
245
297
 
246
298
  DiagnosticLog.notice("[Rejourney] App foregrounded after ${backgroundDuration / 1000}s (timeout: ${SESSION_TIMEOUT_MS / 1000}s)")
247
299
 
300
+ // Resume the heartbeat timer now that we're back in foreground
301
+ TelemetryPipeline.shared?.resume()
302
+
248
303
  if (backgroundDuration > SESSION_TIMEOUT_MS) {
249
304
  // End current session and start a new one
250
305
  state = SessionState.Idle
@@ -252,16 +307,52 @@ class RejourneyModuleImpl(
252
307
 
253
308
  DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '$oldSessionId' and creating new one")
254
309
 
310
+ val restartStarted = AtomicBoolean(false)
311
+ val triggerRestart: (String) -> Unit = { source ->
312
+ if (restartStarted.compareAndSet(false, true)) {
313
+ DiagnosticLog.notice("[Rejourney] Session rollover trigger source=$source, oldSession=$oldSessionId")
314
+ mainHandler.post { startNewSessionAfterTimeout() }
315
+ }
316
+ }
317
+
318
+ mainHandler.postDelayed({
319
+ if (!restartStarted.get()) {
320
+ DiagnosticLog.caution("[Rejourney] Session rollover grace timeout reached (${SESSION_ROLLOVER_GRACE_MS}ms), forcing new session start")
321
+ }
322
+ triggerRestart("grace_timeout")
323
+ }, SESSION_ROLLOVER_GRACE_MS)
324
+
255
325
  backgroundScope.launch {
256
- ReplayOrchestrator.shared?.endReplay { success, uploaded ->
326
+ val orchestrator = ReplayOrchestrator.shared
327
+ if (orchestrator == null) {
328
+ triggerRestart("orchestrator_missing")
329
+ return@launch
330
+ }
331
+
332
+ orchestrator.endReplayWithReason("background_timeout") { success, uploaded ->
257
333
  DiagnosticLog.notice("[Rejourney] Old session ended (success: $success, uploaded: $uploaded)")
258
- mainHandler.post { startNewSessionAfterTimeout() }
334
+ triggerRestart("end_replay_callback")
259
335
  }
260
336
  }
261
337
  } else {
262
- // Resume existing session
263
- state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
264
- DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
338
+ val orchestratorSessionId = ReplayOrchestrator.shared?.replayId
339
+ if (orchestratorSessionId.isNullOrEmpty()) {
340
+ // The old session can end while app is backgrounded (e.g. duration limit).
341
+ // Do not resume a dead session; start a fresh one.
342
+ state = SessionState.Idle
343
+ DiagnosticLog.notice("[Rejourney] Session ended while backgrounded, starting fresh session on foreground")
344
+ mainHandler.post { startNewSessionAfterTimeout() }
345
+ return
346
+ }
347
+
348
+ if (orchestratorSessionId != currentState.sessionId) {
349
+ state = SessionState.Active(orchestratorSessionId, System.currentTimeMillis())
350
+ DiagnosticLog.notice("[Rejourney] ▶️ Foreground reconciled to active session '$orchestratorSessionId' (was '${currentState.sessionId}')")
351
+ } else {
352
+ // Resume existing session
353
+ state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
354
+ DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
355
+ }
265
356
 
266
357
  // Record foreground event
267
358
  TelemetryPipeline.shared?.recordAppForeground(backgroundDuration)
@@ -277,6 +368,14 @@ class RejourneyModuleImpl(
277
368
 
278
369
  DiagnosticLog.notice("[Rejourney] Starting new session after timeout (user: $savedUserId)")
279
370
 
371
+ // Refresh activity on capture components (guards against race with onActivityResumed)
372
+ val activity = reactContext.currentActivity
373
+ if (activity != null) {
374
+ VisualCapture.shared?.setCurrentActivity(activity)
375
+ ViewHierarchyScanner.shared?.setCurrentActivity(activity)
376
+ InteractionRecorder.shared?.setCurrentActivity(activity)
377
+ }
378
+
280
379
  // Try fast path with cached credentials
281
380
  val existingCred = DeviceRegistrar.shared?.uploadCredential
282
381
  if (existingCred != null && DeviceRegistrar.shared?.credentialValid == true) {
@@ -494,6 +593,7 @@ class RejourneyModuleImpl(
494
593
  state = SessionState.Idle
495
594
  }
496
595
 
596
+
497
597
  // Android-specific: Stop SessionLifecycleService
498
598
  stopSessionLifecycleService()
499
599
 
@@ -502,7 +602,7 @@ class RejourneyModuleImpl(
502
602
  return@post
503
603
  }
504
604
 
505
- ReplayOrchestrator.shared?.endReplay { success, uploaded ->
605
+ ReplayOrchestrator.shared?.endReplayWithReason("user_initiated") { success, uploaded ->
506
606
  DiagnosticLog.replayEnded(targetSid)
507
607
 
508
608
  promise.resolve(Arguments.createMap().apply {
@@ -554,6 +654,30 @@ class RejourneyModuleImpl(
554
654
  promise.resolve(currentUserIdentity)
555
655
  }
556
656
 
657
+ fun setAnonymousId(anonymousId: String, promise: Promise) {
658
+ try {
659
+ val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
660
+ if (anonymousId.isBlank()) {
661
+ prefs.edit().remove(KEY_ANONYMOUS_ID).apply()
662
+ } else {
663
+ prefs.edit().putString(KEY_ANONYMOUS_ID, anonymousId).apply()
664
+ }
665
+ } catch (e: Exception) {
666
+ DiagnosticLog.fault("[Rejourney] Failed to persist anonymous ID: ${e.message}")
667
+ }
668
+
669
+ promise.resolve(createResultMap(true))
670
+ }
671
+
672
+ fun getAnonymousId(promise: Promise) {
673
+ try {
674
+ val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
675
+ promise.resolve(prefs.getString(KEY_ANONYMOUS_ID, null))
676
+ } catch (_: Exception) {
677
+ promise.resolve(null)
678
+ }
679
+ }
680
+
557
681
  fun logEvent(eventType: String, details: ReadableMap, promise: Promise) {
558
682
  // Handle network_request events specially
559
683
  if (eventType == "network_request") {
@@ -589,6 +713,17 @@ class RejourneyModuleImpl(
589
713
  return
590
714
  }
591
715
 
716
+ // Handle console log events - preserve type:"log" with level and message
717
+ // so the dashboard replay can display them in the console terminal
718
+ if (eventType == "log") {
719
+ val detailsMap = details.toHashMap()
720
+ val level = detailsMap["level"]?.toString() ?: "log"
721
+ val message = detailsMap["message"]?.toString() ?: ""
722
+ TelemetryPipeline.shared?.recordConsoleLogEvent(level, message)
723
+ promise.resolve(createResultMap(true))
724
+ return
725
+ }
726
+
592
727
  // All other events go through custom event recording
593
728
  val payload = try {
594
729
  val json = org.json.JSONObject(details.toHashMap()).toString()
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ package com.rejourney
18
+
19
+ import android.content.ContentProvider
20
+ import android.content.ContentValues
21
+ import android.database.Cursor
22
+ import android.net.Uri
23
+ import com.facebook.react.modules.network.OkHttpClientFactory
24
+ import com.facebook.react.modules.network.OkHttpClientProvider
25
+ import com.rejourney.recording.RejourneyNetworkInterceptor
26
+
27
+ /**
28
+ * ContentProvider that runs before Application.onCreate() (and thus before the React Native
29
+ * bridge and NetworkingModule are created). It registers our OkHttpClientFactory so the
30
+ * first (and all) OkHttpClient instances created by OkHttpClientProvider already include
31
+ * RejourneyNetworkInterceptor, ensuring native API calls are captured on Android like on iOS.
32
+ */
33
+ class RejourneyOkHttpInitProvider : ContentProvider() {
34
+
35
+ override fun onCreate(): Boolean {
36
+ try {
37
+ OkHttpClientProvider.setOkHttpClientFactory(OkHttpClientFactory {
38
+ OkHttpClientProvider.createClientBuilder()
39
+ .addInterceptor(RejourneyNetworkInterceptor())
40
+ .build()
41
+ })
42
+ } catch (_: Exception) {
43
+ // Ignore; RejourneyModuleImpl will still register factory + CustomClientBuilder later
44
+ }
45
+ return true
46
+ }
47
+
48
+ override fun query(
49
+ uri: Uri,
50
+ projection: Array<out String>?,
51
+ selection: String?,
52
+ selectionArgs: Array<out String>?,
53
+ sortOrder: String?
54
+ ): Cursor? = null
55
+
56
+ override fun getType(uri: Uri): String? = null
57
+
58
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
59
+
60
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
61
+
62
+ override fun update(
63
+ uri: Uri,
64
+ values: ContentValues?,
65
+ selection: String?,
66
+ selectionArgs: Array<out String>?
67
+ ): Int = 0
68
+ }
@@ -22,6 +22,7 @@ import android.os.Build
22
22
  import android.provider.Settings
23
23
  import kotlinx.coroutines.*
24
24
  import okhttp3.*
25
+ import com.rejourney.recording.RejourneyNetworkInterceptor
25
26
  import okhttp3.MediaType.Companion.toMediaType
26
27
  import okhttp3.RequestBody.Companion.toRequestBody
27
28
  import org.json.JSONObject
@@ -64,12 +65,15 @@ class DeviceRegistrar private constructor(private val context: Context) {
64
65
  // Private State
65
66
  private val prefsKey = "com.rejourney.device"
66
67
  private val fingerprintKey = "device_fingerprint"
68
+ private val fallbackIdKey = "device_fallback_id"
67
69
  private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
68
70
 
69
71
  private val httpClient: OkHttpClient = OkHttpClient.Builder()
70
72
  .connectTimeout(5, TimeUnit.SECONDS) // Short timeout for debugging
71
73
  .readTimeout(10, TimeUnit.SECONDS)
72
74
  .writeTimeout(10, TimeUnit.SECONDS)
75
+ // Mirror iOS URLProtocol behaviour: make native SDK HTTP calls observable
76
+ .addInterceptor(RejourneyNetworkInterceptor())
73
77
  .build()
74
78
 
75
79
  init {
@@ -169,7 +173,6 @@ class DeviceRegistrar private constructor(private val context: Context) {
169
173
  var composite = packageName
170
174
  composite += Build.MODEL
171
175
  composite += Build.MANUFACTURER
172
- composite += Build.VERSION.RELEASE
173
176
  composite += getAndroidId()
174
177
 
175
178
  return sha256(composite)
@@ -177,12 +180,27 @@ class DeviceRegistrar private constructor(private val context: Context) {
177
180
 
178
181
  private fun getAndroidId(): String {
179
182
  return try {
180
- Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: java.util.UUID.randomUUID().toString()
183
+ Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
184
+ ?: stableDeviceFallback()
181
185
  } catch (e: Exception) {
182
- java.util.UUID.randomUUID().toString()
186
+ stableDeviceFallback()
183
187
  }
184
188
  }
185
189
 
190
+ /**
191
+ * Returns a SharedPreferences-persisted UUID so the fingerprint stays stable
192
+ * even when ANDROID_ID is unavailable (restricted profiles, some OEM devices).
193
+ */
194
+ private fun stableDeviceFallback(): String {
195
+ val prefs = context.getSharedPreferences(prefsKey, Context.MODE_PRIVATE)
196
+ val existing = prefs.getString(fallbackIdKey, null)
197
+ if (existing != null) return existing
198
+
199
+ val fresh = java.util.UUID.randomUUID().toString()
200
+ prefs.edit().putString(fallbackIdKey, fresh).apply()
201
+ return fresh
202
+ }
203
+
186
204
  // Server Communication
187
205
 
188
206
  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 {