@rejourneyco/react-native 1.0.9 → 1.0.11

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 (34) 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 +54 -0
  4. package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
  5. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +3 -0
  6. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +0 -7
  7. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +4 -1
  8. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +3 -0
  9. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +26 -0
  10. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
  11. package/ios/Engine/RejourneyImpl.swift +5 -0
  12. package/ios/Recording/RejourneyURLProtocol.swift +58 -10
  13. package/ios/Recording/ReplayOrchestrator.swift +3 -1
  14. package/ios/Recording/TelemetryPipeline.swift +28 -2
  15. package/ios/Recording/VisualCapture.swift +25 -21
  16. package/ios/Rejourney.h +4 -0
  17. package/ios/Rejourney.mm +3 -15
  18. package/ios/Utility/DataCompression.swift +2 -2
  19. package/lib/commonjs/expoRouterTracking.js +137 -0
  20. package/lib/commonjs/index.js +176 -19
  21. package/lib/commonjs/sdk/autoTracking.js +100 -89
  22. package/lib/module/expoRouterTracking.js +135 -0
  23. package/lib/module/index.js +175 -13
  24. package/lib/module/sdk/autoTracking.js +98 -89
  25. package/lib/typescript/expoRouterTracking.d.ts +14 -0
  26. package/lib/typescript/index.d.ts +2 -2
  27. package/lib/typescript/sdk/autoTracking.d.ts +11 -0
  28. package/lib/typescript/types/index.d.ts +42 -3
  29. package/package.json +22 -2
  30. package/rejourney.podspec +11 -2
  31. package/src/expoRouterTracking.ts +167 -0
  32. package/src/index.ts +184 -16
  33. package/src/sdk/autoTracking.ts +110 -103
  34. 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 { 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,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, json, ts, null)
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
- GZIPOutputStream(bos).use { gzip ->
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
- // Hook into default session configs
42
- if let method = class_getInstanceMethod(URLSessionConfiguration.self, #selector(getter: URLSessionConfiguration.protocolClasses)) {
43
- let original = method_getImplementation(method)
44
- // Note: Safest swizzling approach for URLSessionConfiguration here is complex,
45
- // standard approach is registering the protocol which covers shared sessions and simple setups.
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: json, timestampMs: ts, completion: nil)
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": device.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": device.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 (tar, gzip, network) happens in background queue
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 name = "\(sessionEpoch)_1_\(timestamp).jpeg"
474
- archive.append(_tarHeader(name: name, size: jpeg.count))
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 _tarHeader(name: String, size: Int) -> Data {
485
- var h = Data(count: 512)
486
- if let nd = name.data(using: .utf8) { h.replaceSubrange(0..<min(100, nd.count), with: nd.prefix(100)) }
487
- "0000644\0".data(using: .utf8).map { h.replaceSubrange(100..<108, with: $0) }
488
- let z = "0000000\0".data(using: .utf8)!
489
- h.replaceSubrange(108..<124, with: z + z)
490
- String(format: "%011o\0", size).data(using: .utf8).map { h.replaceSubrange(124..<136, with: $0) }
491
- String(format: "%011o\0", Int(Date().timeIntervalSince1970)).data(using: .utf8).map { h.replaceSubrange(136..<148, with: $0) }
492
- h[156] = 0x30
493
- " ".data(using: .utf8).map { h.replaceSubrange(148..<156, with: $0) }
494
- let sum = h.reduce(0) { $0 + Int($1) }
495
- String(format: "%06o\0 ", sum).data(using: .utf8).map { h.replaceSubrange(148..<156, with: $0) }
496
- return h
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