@rejourneyco/react-native 1.0.9 → 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 +54 -0
- package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +0 -7
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +4 -1
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +26 -0
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
- package/ios/Engine/RejourneyImpl.swift +5 -0
- package/ios/Recording/RejourneyURLProtocol.swift +58 -10
- package/ios/Recording/ReplayOrchestrator.swift +3 -1
- package/ios/Recording/TelemetryPipeline.swift +28 -2
- package/ios/Recording/VisualCapture.swift +25 -21
- package/ios/Utility/DataCompression.swift +2 -2
- package/lib/commonjs/expoRouterTracking.js +137 -0
- package/lib/commonjs/index.js +176 -19
- package/lib/commonjs/sdk/autoTracking.js +100 -89
- package/lib/module/expoRouterTracking.js +135 -0
- package/lib/module/index.js +175 -13
- package/lib/module/sdk/autoTracking.js +98 -89
- package/lib/typescript/expoRouterTracking.d.ts +14 -0
- package/lib/typescript/index.d.ts +2 -2
- package/lib/typescript/sdk/autoTracking.d.ts +11 -0
- package/lib/typescript/types/index.d.ts +42 -3
- package/package.json +22 -2
- package/src/expoRouterTracking.ts +167 -0
- package/src/index.ts +184 -16
- package/src/sdk/autoTracking.ts +110 -103
- package/src/types/index.ts +43 -3
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,8 +37,14 @@ 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
|
|
@@ -155,6 +161,41 @@ class RejourneyModuleImpl(
|
|
|
155
161
|
StabilityMonitor.getInstance(reactContext).transmitStoredReport()
|
|
156
162
|
}
|
|
157
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
|
+
}
|
|
198
|
+
|
|
158
199
|
// Android-specific: OEM detection and task removed handling
|
|
159
200
|
setupOEMSpecificHandling()
|
|
160
201
|
|
|
@@ -231,6 +272,8 @@ class RejourneyModuleImpl(
|
|
|
231
272
|
// Flush pending data
|
|
232
273
|
TelemetryPipeline.shared?.dispatchNow()
|
|
233
274
|
SegmentDispatcher.shared.shipPending()
|
|
275
|
+
// Stop the heartbeat timer to prevent event uploads while backgrounded
|
|
276
|
+
TelemetryPipeline.shared?.pause()
|
|
234
277
|
}
|
|
235
278
|
}
|
|
236
279
|
}
|
|
@@ -254,6 +297,9 @@ class RejourneyModuleImpl(
|
|
|
254
297
|
|
|
255
298
|
DiagnosticLog.notice("[Rejourney] App foregrounded after ${backgroundDuration / 1000}s (timeout: ${SESSION_TIMEOUT_MS / 1000}s)")
|
|
256
299
|
|
|
300
|
+
// Resume the heartbeat timer now that we're back in foreground
|
|
301
|
+
TelemetryPipeline.shared?.resume()
|
|
302
|
+
|
|
257
303
|
if (backgroundDuration > SESSION_TIMEOUT_MS) {
|
|
258
304
|
// End current session and start a new one
|
|
259
305
|
state = SessionState.Idle
|
|
@@ -322,6 +368,14 @@ class RejourneyModuleImpl(
|
|
|
322
368
|
|
|
323
369
|
DiagnosticLog.notice("[Rejourney] Starting new session after timeout (user: $savedUserId)")
|
|
324
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
|
+
|
|
325
379
|
// Try fast path with cached credentials
|
|
326
380
|
val existingCred = DeviceRegistrar.shared?.uploadCredential
|
|
327
381
|
if (existingCred != null && DeviceRegistrar.shared?.credentialValid == true) {
|
|
@@ -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
|
|
@@ -71,6 +72,8 @@ class DeviceRegistrar private constructor(private val context: Context) {
|
|
|
71
72
|
.connectTimeout(5, TimeUnit.SECONDS) // Short timeout for debugging
|
|
72
73
|
.readTimeout(10, TimeUnit.SECONDS)
|
|
73
74
|
.writeTimeout(10, TimeUnit.SECONDS)
|
|
75
|
+
// Mirror iOS URLProtocol behaviour: make native SDK HTTP calls observable
|
|
76
|
+
.addInterceptor(RejourneyNetworkInterceptor())
|
|
74
77
|
.build()
|
|
75
78
|
|
|
76
79
|
init {
|
|
@@ -19,13 +19,6 @@ class RejourneyNetworkInterceptor : Interceptor {
|
|
|
19
19
|
@Throws(IOException::class)
|
|
20
20
|
override fun intercept(chain: Interceptor.Chain): Response {
|
|
21
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
22
|
val startMs = System.currentTimeMillis()
|
|
30
23
|
|
|
31
24
|
var response: Response? = null
|
|
@@ -31,6 +31,7 @@ import android.view.View
|
|
|
31
31
|
import com.rejourney.engine.DeviceRegistrar
|
|
32
32
|
import com.rejourney.engine.DiagnosticLog
|
|
33
33
|
import com.rejourney.engine.PerformanceSnapshot
|
|
34
|
+
import com.rejourney.utility.gzipCompress
|
|
34
35
|
import org.json.JSONObject
|
|
35
36
|
import java.io.File
|
|
36
37
|
import java.util.*
|
|
@@ -505,6 +506,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
505
506
|
visitedScreens.clear()
|
|
506
507
|
bgTimeMs = 0
|
|
507
508
|
bgStartMs = null
|
|
509
|
+
lastHierarchyHash = null
|
|
508
510
|
|
|
509
511
|
TelemetryPipeline.shared?.currentReplayId = replayId
|
|
510
512
|
SegmentDispatcher.shared.currentReplayId = replayId
|
|
@@ -797,9 +799,10 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
797
799
|
lastHierarchyHash = hash
|
|
798
800
|
|
|
799
801
|
val json = JSONObject(hierarchy).toString().toByteArray(Charsets.UTF_8)
|
|
802
|
+
val compressed = json.gzipCompress() ?: return
|
|
800
803
|
val ts = System.currentTimeMillis()
|
|
801
804
|
|
|
802
|
-
SegmentDispatcher.shared.transmitHierarchy(sid,
|
|
805
|
+
SegmentDispatcher.shared.transmitHierarchy(sid, compressed, ts, null)
|
|
803
806
|
}
|
|
804
807
|
|
|
805
808
|
private fun hierarchyHash(h: Map<String, Any>): String {
|
|
@@ -19,6 +19,7 @@ package com.rejourney.recording
|
|
|
19
19
|
import com.rejourney.engine.DiagnosticLog
|
|
20
20
|
import kotlinx.coroutines.*
|
|
21
21
|
import okhttp3.*
|
|
22
|
+
import com.rejourney.recording.RejourneyNetworkInterceptor
|
|
22
23
|
import okhttp3.MediaType.Companion.toMediaType
|
|
23
24
|
import okhttp3.RequestBody.Companion.toRequestBody
|
|
24
25
|
import org.json.JSONObject
|
|
@@ -127,6 +128,8 @@ class SegmentDispatcher private constructor() {
|
|
|
127
128
|
.connectTimeout(5, TimeUnit.SECONDS) // Short timeout for debugging
|
|
128
129
|
.readTimeout(10, TimeUnit.SECONDS)
|
|
129
130
|
.writeTimeout(10, TimeUnit.SECONDS)
|
|
131
|
+
// Mirror iOS URLProtocol: ensure native upload/auth traffic is captured
|
|
132
|
+
.addInterceptor(RejourneyNetworkInterceptor())
|
|
130
133
|
.build()
|
|
131
134
|
|
|
132
135
|
private val retryQueue = mutableListOf<PendingUpload>()
|
|
@@ -130,6 +130,32 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Pause the heartbeat timer when the app goes to background.
|
|
135
|
+
* This prevents the pipeline from uploading empty event batches
|
|
136
|
+
* while backgrounded, which would inflate session duration.
|
|
137
|
+
*/
|
|
138
|
+
fun pause() {
|
|
139
|
+
heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
140
|
+
heartbeatRunnable = null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resume the heartbeat timer when the app returns to foreground.
|
|
145
|
+
*/
|
|
146
|
+
fun resume() {
|
|
147
|
+
if (heartbeatRunnable != null) return
|
|
148
|
+
mainHandler.post {
|
|
149
|
+
heartbeatRunnable = object : Runnable {
|
|
150
|
+
override fun run() {
|
|
151
|
+
dispatchNow()
|
|
152
|
+
mainHandler.postDelayed(this, 5000)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
mainHandler.postDelayed(heartbeatRunnable!!, 5000)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
133
159
|
fun shutdown() {
|
|
134
160
|
heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
135
161
|
heartbeatRunnable = null
|
|
@@ -17,21 +17,33 @@
|
|
|
17
17
|
package com.rejourney.utility
|
|
18
18
|
|
|
19
19
|
import java.io.ByteArrayOutputStream
|
|
20
|
+
import java.io.OutputStream
|
|
21
|
+
import java.util.zip.Deflater
|
|
20
22
|
import java.util.zip.GZIPOutputStream
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Data compression utilities
|
|
24
26
|
* Android implementation aligned with iOS DataCompression.swift
|
|
27
|
+
* Uses level 9 (BEST_COMPRESSION) for smaller S3 payloads.
|
|
25
28
|
*/
|
|
26
29
|
object DataCompression {
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* GZIPOutputStream that uses Deflater.BEST_COMPRESSION for maximum ratio.
|
|
33
|
+
*/
|
|
34
|
+
private class GzipLevel9OutputStream(out: OutputStream) : GZIPOutputStream(out) {
|
|
35
|
+
init {
|
|
36
|
+
def.setLevel(Deflater.BEST_COMPRESSION)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
27
39
|
|
|
28
40
|
/**
|
|
29
|
-
* Compress data using gzip
|
|
41
|
+
* Compress data using gzip (level 9)
|
|
30
42
|
*/
|
|
31
43
|
fun gzipCompress(data: ByteArray): ByteArray? {
|
|
32
44
|
return try {
|
|
33
45
|
val bos = ByteArrayOutputStream()
|
|
34
|
-
|
|
46
|
+
GzipLevel9OutputStream(bos).use { gzip ->
|
|
35
47
|
gzip.write(data)
|
|
36
48
|
}
|
|
37
49
|
bos.toByteArray()
|
|
@@ -110,6 +110,8 @@ public final class RejourneyImpl: NSObject {
|
|
|
110
110
|
DiagnosticLog.notice("[Rejourney] ⏸️ Session '\(sid)' paused (app backgrounded)")
|
|
111
111
|
TelemetryPipeline.shared.dispatchNow()
|
|
112
112
|
SegmentDispatcher.shared.shipPending()
|
|
113
|
+
// Stop the heartbeat timer to prevent event uploads while backgrounded
|
|
114
|
+
TelemetryPipeline.shared.pause()
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
|
|
@@ -139,6 +141,9 @@ public final class RejourneyImpl: NSObject {
|
|
|
139
141
|
|
|
140
142
|
DiagnosticLog.notice("[Rejourney] App foregrounded after \(Int(backgroundDuration))s (timeout: \(Int(sessionTimeoutSeconds))s)")
|
|
141
143
|
|
|
144
|
+
// Resume the heartbeat timer now that we're back in foreground
|
|
145
|
+
TelemetryPipeline.shared.resume()
|
|
146
|
+
|
|
142
147
|
if backgroundDuration > sessionTimeoutSeconds {
|
|
143
148
|
// End current session and start a new one
|
|
144
149
|
state = .idle
|
|
@@ -38,14 +38,67 @@ public class RejourneyURLProtocol: URLProtocol, URLSessionDataDelegate, URLSessi
|
|
|
38
38
|
|
|
39
39
|
@objc public static func enable() {
|
|
40
40
|
URLProtocol.registerClass(RejourneyURLProtocol.self)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
|
|
42
|
+
// Swizzle URLSessionConfiguration.protocolClasses to automatically inject our protocol
|
|
43
|
+
// into custom sessions (e.g. used by SDWebImage, AlamoFire, etc.)
|
|
44
|
+
swizzleProtocolClasses()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private static var isSwizzled = false
|
|
48
|
+
|
|
49
|
+
/// Store the original IMP so we can call through to it safely.
|
|
50
|
+
private static var originalProtocolClassesIMP: IMP?
|
|
51
|
+
|
|
52
|
+
private static func swizzleProtocolClasses() {
|
|
53
|
+
guard !isSwizzled else { return }
|
|
54
|
+
|
|
55
|
+
let configClass: AnyClass = URLSessionConfiguration.self
|
|
56
|
+
let originalSel = #selector(getter: URLSessionConfiguration.protocolClasses)
|
|
57
|
+
let swizzledSel = #selector(RejourneyURLProtocol.rj_protocolClasses)
|
|
58
|
+
|
|
59
|
+
guard let originalMethod = class_getInstanceMethod(configClass, originalSel),
|
|
60
|
+
let swizzledMethod = class_getInstanceMethod(RejourneyURLProtocol.self, swizzledSel) else {
|
|
61
|
+
return
|
|
46
62
|
}
|
|
63
|
+
|
|
64
|
+
// Add the swizzled method onto URLSessionConfiguration itself so that
|
|
65
|
+
// method_exchangeImplementations works within a single class.
|
|
66
|
+
let didAdd = class_addMethod(
|
|
67
|
+
configClass,
|
|
68
|
+
swizzledSel,
|
|
69
|
+
method_getImplementation(swizzledMethod),
|
|
70
|
+
method_getTypeEncoding(swizzledMethod)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if didAdd, let addedMethod = class_getInstanceMethod(configClass, swizzledSel) {
|
|
74
|
+
originalProtocolClassesIMP = method_getImplementation(originalMethod)
|
|
75
|
+
method_exchangeImplementations(originalMethod, addedMethod)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
isSwizzled = true
|
|
47
79
|
}
|
|
48
80
|
|
|
81
|
+
/// Replacement getter injected into URLSessionConfiguration.
|
|
82
|
+
/// After exchange, `self` IS a URLSessionConfiguration instance.
|
|
83
|
+
@objc private func rj_protocolClasses() -> [AnyClass]? {
|
|
84
|
+
// Call through to the original implementation via the saved IMP
|
|
85
|
+
typealias OriginalFunc = @convention(c) (AnyObject, Selector) -> [AnyClass]?
|
|
86
|
+
var classes: [AnyClass] = []
|
|
87
|
+
|
|
88
|
+
if let imp = RejourneyURLProtocol.originalProtocolClassesIMP {
|
|
89
|
+
let original = unsafeBitCast(imp, to: OriginalFunc.self)
|
|
90
|
+
classes = original(self, #selector(getter: URLSessionConfiguration.protocolClasses)) ?? []
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Inject our protocol at the beginning if not already present
|
|
94
|
+
if !classes.contains(where: { $0 == RejourneyURLProtocol.self }) {
|
|
95
|
+
classes.insert(RejourneyURLProtocol.self, at: 0)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return classes
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
49
102
|
@objc public static func disable() {
|
|
50
103
|
URLProtocol.unregisterClass(RejourneyURLProtocol.self)
|
|
51
104
|
}
|
|
@@ -62,11 +115,6 @@ public class RejourneyURLProtocol: URLProtocol, URLSessionDataDelegate, URLSessi
|
|
|
62
115
|
return false
|
|
63
116
|
}
|
|
64
117
|
|
|
65
|
-
// Ignore requests to the Rejourney API endpoints themselves to prevent ingestion duplication
|
|
66
|
-
if let host = url.host, host.contains("api.rejourney.co") {
|
|
67
|
-
return false
|
|
68
|
-
}
|
|
69
|
-
|
|
70
118
|
return true
|
|
71
119
|
}
|
|
72
120
|
|
|
@@ -451,6 +451,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
451
451
|
_visitedScreens.removeAll()
|
|
452
452
|
_bgTimeMs = 0
|
|
453
453
|
_bgStartMs = nil
|
|
454
|
+
_lastHierarchyHash = nil
|
|
454
455
|
|
|
455
456
|
TelemetryPipeline.shared.currentReplayId = replayId
|
|
456
457
|
SegmentDispatcher.shared.currentReplayId = replayId
|
|
@@ -674,9 +675,10 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
674
675
|
_lastHierarchyHash = hash
|
|
675
676
|
|
|
676
677
|
guard let json = try? JSONSerialization.data(withJSONObject: hierarchy) else { return }
|
|
678
|
+
guard let compressed = json.gzipCompress() else { return }
|
|
677
679
|
let ts = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
678
680
|
|
|
679
|
-
SegmentDispatcher.shared.transmitHierarchy(replayId: sid, hierarchyPayload:
|
|
681
|
+
SegmentDispatcher.shared.transmitHierarchy(replayId: sid, hierarchyPayload: compressed, timestampMs: ts, completion: nil)
|
|
680
682
|
}
|
|
681
683
|
|
|
682
684
|
private func _hierarchyHash(_ h: [String: Any]) -> String {
|
|
@@ -101,6 +101,25 @@ public final class TelemetryPipeline: NSObject {
|
|
|
101
101
|
NotificationCenter.default.addObserver(self, selector: #selector(_appSuspending), name: UIApplication.willTerminateNotification, object: nil)
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/// Pause the heartbeat timer when the app goes to background.
|
|
105
|
+
/// This prevents the pipeline from uploading empty event batches
|
|
106
|
+
/// while backgrounded, which would inflate session duration.
|
|
107
|
+
@objc public func pause() {
|
|
108
|
+
_heartbeat?.invalidate()
|
|
109
|
+
_heartbeat = nil
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Resume the heartbeat timer when the app returns to foreground.
|
|
113
|
+
@objc public func resume() {
|
|
114
|
+
guard _heartbeat == nil else { return }
|
|
115
|
+
DispatchQueue.main.async { [weak self] in
|
|
116
|
+
guard let self else { return }
|
|
117
|
+
self._heartbeat = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
|
118
|
+
self?.dispatchNow()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
@objc public func shutdown() {
|
|
105
124
|
_heartbeat?.invalidate()
|
|
106
125
|
_heartbeat = nil
|
|
@@ -207,9 +226,13 @@ public final class TelemetryPipeline: NSObject {
|
|
|
207
226
|
let isConstrained = ReplayOrchestrator.shared.networkIsConstrained
|
|
208
227
|
let isExpensive = ReplayOrchestrator.shared.networkIsExpensive
|
|
209
228
|
|
|
229
|
+
// Prefer detailed hardware model (e.g. "iPhone16,1") when available,
|
|
230
|
+
// falling back to the generic UIDevice.model ("iPhone", "iPad", etc.).
|
|
231
|
+
let hardwareModel = (DeviceRegistrar.shared.gatherDeviceProfile()["hwModel"] as? String) ?? device.model
|
|
232
|
+
|
|
210
233
|
let meta: [String: Any] = [
|
|
211
234
|
"platform": "ios",
|
|
212
|
-
"model":
|
|
235
|
+
"model": hardwareModel,
|
|
213
236
|
"osVersion": device.systemVersion,
|
|
214
237
|
"vendorId": device.identifierForVendor?.uuidString ?? "",
|
|
215
238
|
"time": Date().timeIntervalSince1970,
|
|
@@ -276,9 +299,12 @@ public final class TelemetryPipeline: NSObject {
|
|
|
276
299
|
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
|
277
300
|
let appId = Bundle.main.bundleIdentifier ?? "unknown"
|
|
278
301
|
|
|
302
|
+
// Prefer detailed hardware model from DeviceRegistrar when available.
|
|
303
|
+
let hardwareModel = (DeviceRegistrar.shared.gatherDeviceProfile()["hwModel"] as? String) ?? device.model
|
|
304
|
+
|
|
279
305
|
let meta: [String: Any] = [
|
|
280
306
|
"platform": "ios",
|
|
281
|
-
"model":
|
|
307
|
+
"model": hardwareModel,
|
|
282
308
|
"osVersion": device.systemVersion,
|
|
283
309
|
"vendorId": device.identifierForVendor?.uuidString ?? "",
|
|
284
310
|
"time": Date().timeIntervalSince1970,
|
|
@@ -327,7 +327,7 @@ public final class VisualCapture: NSObject {
|
|
|
327
327
|
|
|
328
328
|
guard !images.isEmpty else { return }
|
|
329
329
|
|
|
330
|
-
// All heavy work (
|
|
330
|
+
// All heavy work (package, gzip, network) happens in background queue
|
|
331
331
|
_encodeQueue.addOperation { [weak self] in
|
|
332
332
|
self?._packageAndShip(images: images, sessionEpoch: sessionEpoch)
|
|
333
333
|
}
|
|
@@ -466,34 +466,38 @@ public final class VisualCapture: NSObject {
|
|
|
466
466
|
)
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
+
/// Android-compatible binary format: [8-byte BE timestamp offset][4-byte BE size][jpeg] per frame. Backend auto-detects.
|
|
469
470
|
private func _packageFrameBundle(images: [(Data, UInt64)], sessionEpoch: UInt64) -> Data? {
|
|
470
471
|
var archive = Data()
|
|
471
|
-
|
|
472
472
|
for (jpeg, timestamp) in images {
|
|
473
|
-
let
|
|
474
|
-
archive.append(
|
|
473
|
+
let tsOffset = timestamp - sessionEpoch
|
|
474
|
+
archive.append(_uint64BigEndian(tsOffset))
|
|
475
|
+
archive.append(_uint32BigEndian(UInt32(jpeg.count)))
|
|
475
476
|
archive.append(jpeg)
|
|
476
|
-
let padding = (512 - (jpeg.count % 512)) % 512
|
|
477
|
-
if padding > 0 { archive.append(Data(repeating: 0, count: padding)) }
|
|
478
477
|
}
|
|
479
|
-
|
|
480
|
-
archive.append(Data(repeating: 0, count: 1024))
|
|
481
478
|
return archive.gzipCompress()
|
|
482
479
|
}
|
|
483
480
|
|
|
484
|
-
private func
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
481
|
+
private func _uint64BigEndian(_ value: UInt64) -> Data {
|
|
482
|
+
Data([
|
|
483
|
+
UInt8((value >> 56) & 0xff),
|
|
484
|
+
UInt8((value >> 48) & 0xff),
|
|
485
|
+
UInt8((value >> 40) & 0xff),
|
|
486
|
+
UInt8((value >> 32) & 0xff),
|
|
487
|
+
UInt8((value >> 24) & 0xff),
|
|
488
|
+
UInt8((value >> 16) & 0xff),
|
|
489
|
+
UInt8((value >> 8) & 0xff),
|
|
490
|
+
UInt8(value & 0xff)
|
|
491
|
+
])
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private func _uint32BigEndian(_ value: UInt32) -> Data {
|
|
495
|
+
Data([
|
|
496
|
+
UInt8((value >> 24) & 0xff),
|
|
497
|
+
UInt8((value >> 16) & 0xff),
|
|
498
|
+
UInt8((value >> 8) & 0xff),
|
|
499
|
+
UInt8(value & 0xff)
|
|
500
|
+
])
|
|
497
501
|
}
|
|
498
502
|
}
|
|
499
503
|
|