@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.
- package/README.md +77 -3
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
- package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +204 -115
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +1 -0
- package/ios/Recording/RejourneyURLProtocol.swift +216 -0
- package/ios/Recording/ReplayOrchestrator.swift +207 -144
- package/ios/Recording/SegmentDispatcher.swift +8 -0
- package/ios/Recording/StabilityMonitor.swift +40 -32
- package/ios/Recording/TelemetryPipeline.swift +45 -2
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +79 -29
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/DataCompression.swift +2 -2
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/expoRouterTracking.js +137 -0
- package/lib/commonjs/index.js +204 -34
- package/lib/commonjs/sdk/autoTracking.js +262 -100
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/module/expoRouterTracking.js +135 -0
- package/lib/module/index.js +203 -28
- package/lib/module/sdk/autoTracking.js +260 -100
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/expoRouterTracking.d.ts +14 -0
- package/lib/typescript/index.d.ts +2 -2
- package/lib/typescript/sdk/autoTracking.d.ts +14 -1
- package/lib/typescript/types/index.d.ts +56 -5
- package/package.json +23 -3
- package/src/NativeRejourney.ts +8 -5
- package/src/expoRouterTracking.ts +167 -0
- package/src/index.ts +221 -35
- package/src/sdk/autoTracking.ts +286 -114
- package/src/sdk/networkInterceptor.ts +110 -1
- 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 {
|
|
14
|
+
import { Rejourney } from '@rejourneyco/react-native';
|
|
15
15
|
|
|
16
16
|
// Initialize with your public key
|
|
17
|
-
|
|
17
|
+
Rejourney.init('pk_live_xxxxxxxxxxxx');
|
|
18
18
|
|
|
19
19
|
// Start recording after obtaining user consent
|
|
20
|
-
|
|
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
|
-
//
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
334
|
+
triggerRestart("end_replay_callback")
|
|
259
335
|
}
|
|
260
336
|
}
|
|
261
337
|
} else {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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?.
|
|
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)
|
|
183
|
+
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
|
184
|
+
?: stableDeviceFallback()
|
|
181
185
|
} catch (e: Exception) {
|
|
182
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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?.
|
|
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 {
|