@rejourneyco/react-native 1.0.0

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 (152) hide show
  1. package/android/build.gradle.kts +135 -0
  2. package/android/consumer-rules.pro +10 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/main/AndroidManifest.xml +15 -0
  5. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
  6. package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
  7. package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
  8. package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
  9. package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
  10. package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
  11. package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
  12. package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
  13. package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
  14. package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
  15. package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
  16. package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
  17. package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
  18. package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
  19. package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
  20. package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
  21. package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
  22. package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
  23. package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
  24. package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
  25. package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
  26. package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
  27. package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
  28. package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
  29. package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
  30. package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
  31. package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
  32. package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
  33. package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
  34. package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
  35. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
  36. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  37. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
  38. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  39. package/ios/Capture/RJANRHandler.h +42 -0
  40. package/ios/Capture/RJANRHandler.m +328 -0
  41. package/ios/Capture/RJCaptureEngine.h +275 -0
  42. package/ios/Capture/RJCaptureEngine.m +2062 -0
  43. package/ios/Capture/RJCaptureHeuristics.h +80 -0
  44. package/ios/Capture/RJCaptureHeuristics.m +903 -0
  45. package/ios/Capture/RJCrashHandler.h +46 -0
  46. package/ios/Capture/RJCrashHandler.m +313 -0
  47. package/ios/Capture/RJMotionEvent.h +183 -0
  48. package/ios/Capture/RJMotionEvent.m +183 -0
  49. package/ios/Capture/RJPerformanceManager.h +100 -0
  50. package/ios/Capture/RJPerformanceManager.m +373 -0
  51. package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
  52. package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
  53. package/ios/Capture/RJSegmentUploader.h +146 -0
  54. package/ios/Capture/RJSegmentUploader.m +778 -0
  55. package/ios/Capture/RJVideoEncoder.h +247 -0
  56. package/ios/Capture/RJVideoEncoder.m +1036 -0
  57. package/ios/Capture/RJViewControllerTracker.h +73 -0
  58. package/ios/Capture/RJViewControllerTracker.m +508 -0
  59. package/ios/Capture/RJViewHierarchyScanner.h +215 -0
  60. package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
  61. package/ios/Capture/RJViewSerializer.h +119 -0
  62. package/ios/Capture/RJViewSerializer.m +498 -0
  63. package/ios/Core/RJConstants.h +124 -0
  64. package/ios/Core/RJConstants.m +88 -0
  65. package/ios/Core/RJLifecycleManager.h +85 -0
  66. package/ios/Core/RJLifecycleManager.m +308 -0
  67. package/ios/Core/RJLogger.h +61 -0
  68. package/ios/Core/RJLogger.m +211 -0
  69. package/ios/Core/RJTypes.h +176 -0
  70. package/ios/Core/RJTypes.m +66 -0
  71. package/ios/Core/Rejourney.h +64 -0
  72. package/ios/Core/Rejourney.mm +2495 -0
  73. package/ios/Network/RJDeviceAuthManager.h +94 -0
  74. package/ios/Network/RJDeviceAuthManager.m +967 -0
  75. package/ios/Network/RJNetworkMonitor.h +68 -0
  76. package/ios/Network/RJNetworkMonitor.m +267 -0
  77. package/ios/Network/RJRetryManager.h +73 -0
  78. package/ios/Network/RJRetryManager.m +325 -0
  79. package/ios/Network/RJUploadManager.h +267 -0
  80. package/ios/Network/RJUploadManager.m +2296 -0
  81. package/ios/Privacy/RJPrivacyMask.h +163 -0
  82. package/ios/Privacy/RJPrivacyMask.m +922 -0
  83. package/ios/Rejourney.h +63 -0
  84. package/ios/Touch/RJGestureClassifier.h +130 -0
  85. package/ios/Touch/RJGestureClassifier.m +333 -0
  86. package/ios/Touch/RJTouchInterceptor.h +169 -0
  87. package/ios/Touch/RJTouchInterceptor.m +772 -0
  88. package/ios/Utils/RJEventBuffer.h +112 -0
  89. package/ios/Utils/RJEventBuffer.m +358 -0
  90. package/ios/Utils/RJGzipUtils.h +33 -0
  91. package/ios/Utils/RJGzipUtils.m +89 -0
  92. package/ios/Utils/RJKeychainManager.h +48 -0
  93. package/ios/Utils/RJKeychainManager.m +111 -0
  94. package/ios/Utils/RJPerfTiming.h +209 -0
  95. package/ios/Utils/RJPerfTiming.m +264 -0
  96. package/ios/Utils/RJTelemetry.h +92 -0
  97. package/ios/Utils/RJTelemetry.m +320 -0
  98. package/ios/Utils/RJWindowUtils.h +66 -0
  99. package/ios/Utils/RJWindowUtils.m +133 -0
  100. package/lib/commonjs/NativeRejourney.js +40 -0
  101. package/lib/commonjs/components/Mask.js +79 -0
  102. package/lib/commonjs/index.js +1381 -0
  103. package/lib/commonjs/sdk/autoTracking.js +1259 -0
  104. package/lib/commonjs/sdk/constants.js +151 -0
  105. package/lib/commonjs/sdk/errorTracking.js +199 -0
  106. package/lib/commonjs/sdk/index.js +50 -0
  107. package/lib/commonjs/sdk/metricsTracking.js +204 -0
  108. package/lib/commonjs/sdk/navigation.js +151 -0
  109. package/lib/commonjs/sdk/networkInterceptor.js +412 -0
  110. package/lib/commonjs/sdk/utils.js +363 -0
  111. package/lib/commonjs/types/expo-router.d.js +2 -0
  112. package/lib/commonjs/types/index.js +2 -0
  113. package/lib/module/NativeRejourney.js +38 -0
  114. package/lib/module/components/Mask.js +72 -0
  115. package/lib/module/index.js +1284 -0
  116. package/lib/module/sdk/autoTracking.js +1233 -0
  117. package/lib/module/sdk/constants.js +145 -0
  118. package/lib/module/sdk/errorTracking.js +189 -0
  119. package/lib/module/sdk/index.js +12 -0
  120. package/lib/module/sdk/metricsTracking.js +187 -0
  121. package/lib/module/sdk/navigation.js +143 -0
  122. package/lib/module/sdk/networkInterceptor.js +401 -0
  123. package/lib/module/sdk/utils.js +342 -0
  124. package/lib/module/types/expo-router.d.js +2 -0
  125. package/lib/module/types/index.js +2 -0
  126. package/lib/typescript/NativeRejourney.d.ts +147 -0
  127. package/lib/typescript/components/Mask.d.ts +39 -0
  128. package/lib/typescript/index.d.ts +117 -0
  129. package/lib/typescript/sdk/autoTracking.d.ts +204 -0
  130. package/lib/typescript/sdk/constants.d.ts +120 -0
  131. package/lib/typescript/sdk/errorTracking.d.ts +32 -0
  132. package/lib/typescript/sdk/index.d.ts +9 -0
  133. package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
  134. package/lib/typescript/sdk/navigation.d.ts +33 -0
  135. package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
  136. package/lib/typescript/sdk/utils.d.ts +148 -0
  137. package/lib/typescript/types/index.d.ts +624 -0
  138. package/package.json +102 -0
  139. package/rejourney.podspec +21 -0
  140. package/src/NativeRejourney.ts +165 -0
  141. package/src/components/Mask.tsx +80 -0
  142. package/src/index.ts +1459 -0
  143. package/src/sdk/autoTracking.ts +1373 -0
  144. package/src/sdk/constants.ts +134 -0
  145. package/src/sdk/errorTracking.ts +231 -0
  146. package/src/sdk/index.ts +11 -0
  147. package/src/sdk/metricsTracking.ts +232 -0
  148. package/src/sdk/navigation.ts +157 -0
  149. package/src/sdk/networkInterceptor.ts +440 -0
  150. package/src/sdk/utils.ts +369 -0
  151. package/src/types/expo-router.d.ts +7 -0
  152. package/src/types/index.ts +739 -0
@@ -0,0 +1,1373 @@
1
+ /**
2
+ * Rejourney Auto Tracking Module
3
+ *
4
+ * Automatic tracking features that work with just init() - no additional code needed.
5
+ * This module handles:
6
+ * - Rage tap detection
7
+ * - Error tracking (JS + React Native)
8
+ * - Session metrics aggregation
9
+ * - Device info collection
10
+ * - Anonymous ID generation
11
+ * - Funnel/screen tracking
12
+ * - Score calculations
13
+ *
14
+ * IMPORTANT: This file uses lazy loading for react-native imports to avoid
15
+ * "PlatformConstants could not be found" errors on RN 0.81+.
16
+ */
17
+
18
+ import type {
19
+ DeviceInfo,
20
+ ErrorEvent,
21
+ } from '../types';
22
+ import { logger } from './utils';
23
+
24
+ // Lazy-loaded React Native modules
25
+ let _RN: typeof import('react-native') | null = null;
26
+
27
+ function getRN(): typeof import('react-native') | null {
28
+ if (_RN) return _RN;
29
+ try {
30
+ _RN = require('react-native');
31
+ return _RN;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function getPlatform() {
38
+ return getRN()?.Platform;
39
+ }
40
+
41
+ function getDimensions() {
42
+ return getRN()?.Dimensions;
43
+ }
44
+
45
+ function getNativeModules() {
46
+ return getRN()?.NativeModules;
47
+ }
48
+
49
+ function getRejourneyNativeModule() {
50
+ const RN = getRN();
51
+ if (!RN) return null;
52
+
53
+ const { TurboModuleRegistry, NativeModules } = RN;
54
+ let nativeModule = null;
55
+
56
+ if (TurboModuleRegistry && typeof TurboModuleRegistry.get === 'function') {
57
+ try {
58
+ nativeModule = TurboModuleRegistry.get('Rejourney');
59
+ } catch {
60
+ // Ignore and fall back to NativeModules
61
+ }
62
+ }
63
+
64
+ if (!nativeModule && NativeModules) {
65
+ nativeModule = NativeModules.Rejourney ?? null;
66
+ }
67
+
68
+ return nativeModule;
69
+ }
70
+
71
+ // Type declarations for browser globals (only used in hybrid apps where DOM is available)
72
+ // These don't exist in pure React Native but are needed for error tracking in hybrid scenarios
73
+ type OnErrorEventHandler = ((
74
+ event: Event | string,
75
+ source?: string,
76
+ lineno?: number,
77
+ colno?: number,
78
+ error?: Error
79
+ ) => boolean | void) | null;
80
+
81
+ interface PromiseRejectionEvent {
82
+ reason?: any;
83
+ promise?: Promise<any>;
84
+ }
85
+
86
+ // Cast globalThis to work with both RN and hybrid scenarios
87
+ const _globalThis = globalThis as typeof globalThis & {
88
+ onerror?: OnErrorEventHandler;
89
+ addEventListener?: (type: string, handler: (event: any) => void) => void;
90
+ removeEventListener?: (type: string, handler: (event: any) => void) => void;
91
+ ErrorUtils?: {
92
+ getGlobalHandler: () => ((error: Error, isFatal: boolean) => void) | undefined;
93
+ setGlobalHandler: (handler: (error: Error, isFatal: boolean) => void) => void;
94
+ };
95
+ };
96
+
97
+ // =============================================================================
98
+ // Types
99
+ // =============================================================================
100
+
101
+ export interface TapEvent {
102
+ x: number;
103
+ y: number;
104
+ timestamp: number;
105
+ targetId?: string;
106
+ }
107
+
108
+ export interface SessionMetrics {
109
+ // Core counts
110
+ totalEvents: number;
111
+ touchCount: number;
112
+ scrollCount: number;
113
+ gestureCount: number;
114
+ inputCount: number;
115
+ navigationCount: number;
116
+
117
+ // Issue tracking
118
+ errorCount: number;
119
+ rageTapCount: number;
120
+
121
+ // API metrics
122
+ apiSuccessCount: number;
123
+ apiErrorCount: number;
124
+ apiTotalCount: number;
125
+
126
+ // Network timing (for avg calculation)
127
+ netTotalDurationMs: number; // Sum of all API durations
128
+ netTotalBytes: number; // Sum of all response bytes
129
+
130
+ // Screen tracking for funnels
131
+ screensVisited: string[];
132
+ uniqueScreensCount: number;
133
+
134
+ // Scores (0-100)
135
+ interactionScore: number;
136
+ explorationScore: number;
137
+ uxScore: number;
138
+ }
139
+
140
+ export interface AutoTrackingConfig {
141
+ // Rage tap detection
142
+ rageTapThreshold?: number; // Number of taps (default: 3)
143
+ rageTapTimeWindow?: number; // Time window in ms (default: 500)
144
+ rageTapRadius?: number; // Max radius in pixels (default: 50)
145
+
146
+ // Error tracking
147
+ trackJSErrors?: boolean; // Track window errors (default: true)
148
+ trackPromiseRejections?: boolean; // Track unhandled rejections (default: true)
149
+ trackReactNativeErrors?: boolean; // Track RN ErrorUtils (default: true)
150
+
151
+ // Session settings
152
+ collectDeviceInfo?: boolean; // Collect device info (default: true)
153
+ maxSessionDurationMs?: number; // Clamp to 1–10 minutes; default set from server/config
154
+ }
155
+
156
+ // =============================================================================
157
+ // State
158
+ // =============================================================================
159
+
160
+ let isInitialized = false;
161
+ let config: AutoTrackingConfig = {};
162
+
163
+ // Rage tap tracking
164
+ const recentTaps: TapEvent[] = [];
165
+ let tapHead = 0; // Circular buffer head pointer
166
+ let tapCount = 0; // Actual count of taps in buffer
167
+ const MAX_RECENT_TAPS = 10;
168
+
169
+ // Session metrics
170
+ let metrics: SessionMetrics = createEmptyMetrics();
171
+ let sessionStartTime: number = 0;
172
+ let maxSessionDurationMs: number = 10 * 60 * 1000;
173
+
174
+ // Screen tracking
175
+ let currentScreen = '';
176
+ let screensVisited: string[] = [];
177
+
178
+ // Anonymous ID
179
+ let anonymousId: string | null = null;
180
+ let anonymousIdPromise: Promise<string> | null = null;
181
+
182
+ // Callbacks
183
+ let onRageTapDetected: ((count: number, x: number, y: number) => void) | null = null;
184
+ let onErrorCaptured: ((error: ErrorEvent) => void) | null = null;
185
+ let onScreenChange: ((screenName: string, previousScreen?: string) => void) | null = null;
186
+
187
+ // Original error handlers (for restoration)
188
+ let originalErrorHandler: ((error: Error, isFatal: boolean) => void) | undefined;
189
+ let originalOnError: OnErrorEventHandler | null = null;
190
+ let originalOnUnhandledRejection: ((event: PromiseRejectionEvent) => void) | null = null;
191
+
192
+ // =============================================================================
193
+ // Initialization
194
+ // =============================================================================
195
+
196
+ /**
197
+ * Initialize auto tracking features
198
+ * Called automatically by Rejourney.init() - no user action needed
199
+ */
200
+ export function initAutoTracking(
201
+ trackingConfig: AutoTrackingConfig,
202
+ callbacks: {
203
+ onRageTap?: (count: number, x: number, y: number) => void;
204
+ onError?: (error: ErrorEvent) => void;
205
+ onScreen?: (screenName: string, previousScreen?: string) => void;
206
+ } = {}
207
+ ): void {
208
+ if (isInitialized) return;
209
+
210
+ config = {
211
+ rageTapThreshold: 3,
212
+ rageTapTimeWindow: 500,
213
+ rageTapRadius: 50,
214
+ trackJSErrors: true,
215
+ trackPromiseRejections: true,
216
+ trackReactNativeErrors: true,
217
+ collectDeviceInfo: true,
218
+ maxSessionDurationMs: trackingConfig.maxSessionDurationMs,
219
+ ...trackingConfig,
220
+ };
221
+
222
+ // Session timing
223
+ sessionStartTime = Date.now();
224
+ setMaxSessionDurationMinutes(
225
+ trackingConfig.maxSessionDurationMs
226
+ ? trackingConfig.maxSessionDurationMs / 60000
227
+ : undefined
228
+ );
229
+
230
+ // Set callbacks
231
+ onRageTapDetected = callbacks.onRageTap || null;
232
+ onErrorCaptured = callbacks.onError || null;
233
+ onScreenChange = callbacks.onScreen || null;
234
+
235
+ // Initialize metrics
236
+ metrics = createEmptyMetrics();
237
+ sessionStartTime = Date.now();
238
+ anonymousId = generateAnonymousId();
239
+
240
+ // Setup error tracking
241
+ setupErrorTracking();
242
+
243
+ // Setup React Navigation tracking (if available)
244
+ setupNavigationTracking();
245
+
246
+ isInitialized = true;
247
+ }
248
+
249
+ /**
250
+ * Cleanup auto tracking features
251
+ */
252
+ export function cleanupAutoTracking(): void {
253
+ if (!isInitialized) return;
254
+
255
+ // Restore original error handlers
256
+ restoreErrorHandlers();
257
+
258
+ // Cleanup navigation tracking
259
+ cleanupNavigationTracking();
260
+
261
+ // Reset state
262
+ tapHead = 0;
263
+ tapCount = 0;
264
+ metrics = createEmptyMetrics();
265
+ screensVisited = [];
266
+ currentScreen = '';
267
+ sessionStartTime = 0;
268
+ maxSessionDurationMs = 10 * 60 * 1000;
269
+ isInitialized = false;
270
+ }
271
+
272
+ // =============================================================================
273
+ // Rage Tap Detection
274
+ // =============================================================================
275
+
276
+ /**
277
+ * Track a tap event for rage tap detection
278
+ * Called automatically from touch interceptor
279
+ */
280
+ export function trackTap(tap: TapEvent): void {
281
+ if (!isInitialized) return;
282
+
283
+ const now = Date.now();
284
+
285
+ // Add to circular buffer (O(1) instead of shift() which is O(n))
286
+ const insertIndex = (tapHead + tapCount) % MAX_RECENT_TAPS;
287
+ if (tapCount < MAX_RECENT_TAPS) {
288
+ recentTaps[insertIndex] = { ...tap, timestamp: now };
289
+ tapCount++;
290
+ } else {
291
+ // Buffer full, overwrite oldest
292
+ recentTaps[tapHead] = { ...tap, timestamp: now };
293
+ tapHead = (tapHead + 1) % MAX_RECENT_TAPS;
294
+ }
295
+
296
+ // Evict old taps outside time window
297
+ const windowStart = now - (config.rageTapTimeWindow || 500);
298
+ while (tapCount > 0) {
299
+ const oldestTap = recentTaps[tapHead];
300
+ if (oldestTap && oldestTap.timestamp < windowStart) {
301
+ tapHead = (tapHead + 1) % MAX_RECENT_TAPS;
302
+ tapCount--;
303
+ } else {
304
+ break;
305
+ }
306
+ }
307
+
308
+ // Check for rage tap
309
+ detectRageTap();
310
+
311
+ // Update metrics
312
+ metrics.touchCount++;
313
+ metrics.totalEvents++;
314
+ }
315
+
316
+ /**
317
+ * Detect if recent taps form a rage tap pattern
318
+ */
319
+ function detectRageTap(): void {
320
+ const threshold = config.rageTapThreshold || 3;
321
+ const radius = config.rageTapRadius || 50;
322
+
323
+ if (tapCount < threshold) return;
324
+ // Check last N taps from circular buffer
325
+ const tapsToCheck: TapEvent[] = [];
326
+ for (let i = 0; i < threshold; i++) {
327
+ const idx = (tapHead + tapCount - threshold + i) % MAX_RECENT_TAPS;
328
+ tapsToCheck.push(recentTaps[idx]!);
329
+ }
330
+
331
+ // Calculate center point
332
+ let centerX = 0;
333
+ let centerY = 0;
334
+ for (const tap of tapsToCheck) {
335
+ centerX += tap.x;
336
+ centerY += tap.y;
337
+ }
338
+ centerX /= tapsToCheck.length;
339
+ centerY /= tapsToCheck.length;
340
+
341
+ // Check if all taps are within radius of center
342
+ let allWithinRadius = true;
343
+ for (const tap of tapsToCheck) {
344
+ const dx = tap.x - centerX;
345
+ const dy = tap.y - centerY;
346
+ const distance = Math.sqrt(dx * dx + dy * dy);
347
+ if (distance > radius) {
348
+ allWithinRadius = false;
349
+ break;
350
+ }
351
+ }
352
+
353
+ if (allWithinRadius) {
354
+ // Rage tap detected!
355
+ metrics.rageTapCount++;
356
+ // Clear circular buffer to prevent duplicate detection
357
+ tapHead = 0;
358
+ tapCount = 0;
359
+
360
+ // Notify callback
361
+ if (onRageTapDetected) {
362
+ onRageTapDetected(threshold, centerX, centerY);
363
+ }
364
+ }
365
+ }
366
+
367
+ // =============================================================================
368
+ // State Change Notification
369
+ // =============================================================================
370
+
371
+ /**
372
+ * Notify that a state change occurred (navigation, modal, etc.)
373
+ * Kept for API compatibility
374
+ */
375
+ export function notifyStateChange(): void {
376
+ // No-op - kept for backward compatibility
377
+ }
378
+
379
+ // =============================================================================
380
+ // Error Tracking
381
+ // =============================================================================
382
+
383
+ /**
384
+ * Setup automatic error tracking
385
+ */
386
+ function setupErrorTracking(): void {
387
+ // Track React Native errors
388
+ if (config.trackReactNativeErrors !== false) {
389
+ setupReactNativeErrorHandler();
390
+ }
391
+
392
+ // Track JavaScript errors (only works in web/debug)
393
+ if (config.trackJSErrors !== false && typeof _globalThis !== 'undefined') {
394
+ setupJSErrorHandler();
395
+ }
396
+
397
+ // Track unhandled promise rejections
398
+ if (config.trackPromiseRejections !== false && typeof _globalThis !== 'undefined') {
399
+ setupPromiseRejectionHandler();
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Setup React Native ErrorUtils handler
405
+ */
406
+ function setupReactNativeErrorHandler(): void {
407
+ try {
408
+ // Access ErrorUtils from global scope
409
+ const ErrorUtils = _globalThis.ErrorUtils;
410
+ if (!ErrorUtils) return;
411
+
412
+ // Store original handler
413
+ originalErrorHandler = ErrorUtils.getGlobalHandler();
414
+
415
+ // Set new handler
416
+ ErrorUtils.setGlobalHandler((error: Error, isFatal: boolean) => {
417
+ // Track the error
418
+ trackError({
419
+ type: 'error',
420
+ timestamp: Date.now(),
421
+ message: error.message || String(error),
422
+ stack: error.stack,
423
+ name: error.name || 'Error',
424
+ });
425
+
426
+ // Call original handler
427
+ if (originalErrorHandler) {
428
+ originalErrorHandler(error, isFatal);
429
+ }
430
+ });
431
+ } catch {
432
+ // ErrorUtils not available
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Setup global JS error handler
438
+ */
439
+ function setupJSErrorHandler(): void {
440
+ if (typeof _globalThis.onerror !== 'undefined') {
441
+ // Note: In React Native, this typically doesn't fire
442
+ // But we set it up anyway for hybrid apps
443
+ originalOnError = _globalThis.onerror;
444
+
445
+ _globalThis.onerror = (
446
+ message: string | Event,
447
+ source?: string,
448
+ lineno?: number,
449
+ colno?: number,
450
+ error?: Error
451
+ ) => {
452
+ trackError({
453
+ type: 'error',
454
+ timestamp: Date.now(),
455
+ message: typeof message === 'string' ? message : 'Unknown error',
456
+ stack: error?.stack || `${source}:${lineno}:${colno}`,
457
+ name: error?.name || 'Error',
458
+ });
459
+
460
+ // Call original handler
461
+ if (originalOnError) {
462
+ return originalOnError(message, source, lineno, colno, error);
463
+ }
464
+ return false;
465
+ };
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Setup unhandled promise rejection handler
471
+ */
472
+ function setupPromiseRejectionHandler(): void {
473
+ if (typeof _globalThis.addEventListener !== 'undefined') {
474
+ const handler = (event: PromiseRejectionEvent) => {
475
+ const reason = event.reason;
476
+ trackError({
477
+ type: 'error',
478
+ timestamp: Date.now(),
479
+ message: reason?.message || String(reason) || 'Unhandled Promise Rejection',
480
+ stack: reason?.stack,
481
+ name: reason?.name || 'UnhandledRejection',
482
+ });
483
+ };
484
+
485
+ originalOnUnhandledRejection = handler;
486
+ _globalThis.addEventListener!('unhandledrejection', handler);
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Restore original error handlers
492
+ */
493
+ function restoreErrorHandlers(): void {
494
+ // Restore React Native handler
495
+ if (originalErrorHandler) {
496
+ try {
497
+ const ErrorUtils = _globalThis.ErrorUtils;
498
+ if (ErrorUtils) {
499
+ ErrorUtils.setGlobalHandler(originalErrorHandler);
500
+ }
501
+ } catch {
502
+ // Ignore
503
+ }
504
+ originalErrorHandler = undefined;
505
+ }
506
+
507
+ // Restore global onerror
508
+ if (originalOnError !== null) {
509
+ _globalThis.onerror = originalOnError;
510
+ originalOnError = null;
511
+ }
512
+
513
+ // Remove promise rejection handler
514
+ if (originalOnUnhandledRejection && typeof _globalThis.removeEventListener !== 'undefined') {
515
+ _globalThis.removeEventListener!('unhandledrejection', originalOnUnhandledRejection);
516
+ originalOnUnhandledRejection = null;
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Track an error
522
+ */
523
+ function trackError(error: ErrorEvent): void {
524
+ metrics.errorCount++;
525
+ metrics.totalEvents++;
526
+
527
+ if (onErrorCaptured) {
528
+ onErrorCaptured(error);
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Manually track an error (for API errors, etc.)
534
+ */
535
+ export function captureError(
536
+ message: string,
537
+ stack?: string,
538
+ name?: string
539
+ ): void {
540
+ trackError({
541
+ type: 'error',
542
+ timestamp: Date.now(),
543
+ message,
544
+ stack,
545
+ name: name || 'Error',
546
+ });
547
+ }
548
+
549
+ // =============================================================================
550
+ // Screen/Funnel Tracking - Automatic Navigation Detection
551
+ // =============================================================================
552
+
553
+ // Navigation detection state
554
+ let navigationPollingInterval: ReturnType<typeof setInterval> | null = null;
555
+ let lastDetectedScreen = '';
556
+ let navigationSetupDone = false;
557
+ let navigationPollingErrors = 0; // Track consecutive errors
558
+ const MAX_POLLING_ERRORS = 10; // Stop polling after 10 consecutive errors
559
+
560
+ /**
561
+ * Track a navigation state change from React Navigation.
562
+ *
563
+ * For bare React Native apps using @react-navigation/native.
564
+ * Just add this to your NavigationContainer's onStateChange prop.
565
+ *
566
+ * @example
567
+ * ```tsx
568
+ * import { trackNavigationState } from 'rejourney';
569
+ *
570
+ * <NavigationContainer onStateChange={trackNavigationState}>
571
+ * ...
572
+ * </NavigationContainer>
573
+ * ```
574
+ */
575
+ export function trackNavigationState(state: any): void {
576
+ if (!state?.routes) return;
577
+
578
+ try {
579
+ const { normalizeScreenName } = require('./navigation');
580
+
581
+ // Find the active screen recursively
582
+ const findActiveScreen = (navState: any): string | null => {
583
+ if (!navState?.routes) return null;
584
+ const index = navState.index ?? navState.routes.length - 1;
585
+ const route = navState.routes[index];
586
+ if (!route) return null;
587
+ if (route.state) return findActiveScreen(route.state);
588
+ return normalizeScreenName(route.name || 'Unknown');
589
+ };
590
+
591
+ const screenName = findActiveScreen(state);
592
+ if (screenName && screenName !== lastDetectedScreen) {
593
+ lastDetectedScreen = screenName;
594
+ trackScreen(screenName);
595
+ }
596
+ } catch {
597
+ // Navigation tracking error
598
+ }
599
+ }
600
+
601
+ /**
602
+ * React hook for navigation tracking.
603
+ *
604
+ * Returns props to spread on NavigationContainer that will:
605
+ * 1. Track the initial screen on mount (via onReady)
606
+ * 2. Track all subsequent navigations (via onStateChange)
607
+ *
608
+ * This is the RECOMMENDED approach for bare React Native apps.
609
+ *
610
+ * @example
611
+ * ```tsx
612
+ * import { useNavigationTracking } from 'rejourney';
613
+ * import { NavigationContainer } from '@react-navigation/native';
614
+ *
615
+ * function App() {
616
+ * const navigationTracking = useNavigationTracking();
617
+ *
618
+ * return (
619
+ * <NavigationContainer {...navigationTracking}>
620
+ * <RootNavigator />
621
+ * </NavigationContainer>
622
+ * );
623
+ * }
624
+ * ```
625
+ */
626
+ export function useNavigationTracking() {
627
+ // Use React's useRef and useCallback to create stable references
628
+ const React = require('react');
629
+ const { createNavigationContainerRef } = require('@react-navigation/native');
630
+
631
+ // Create a stable navigation ref
632
+ const navigationRef = React.useRef(createNavigationContainerRef());
633
+
634
+ // Track initial screen when navigation is ready
635
+ const onReady = React.useCallback(() => {
636
+ try {
637
+ const currentRoute = navigationRef.current?.getCurrentRoute?.();
638
+ if (currentRoute?.name) {
639
+ const { normalizeScreenName } = require('./navigation');
640
+ const screenName = normalizeScreenName(currentRoute.name);
641
+ if (screenName && screenName !== lastDetectedScreen) {
642
+ lastDetectedScreen = screenName;
643
+ trackScreen(screenName);
644
+ }
645
+ }
646
+ } catch {
647
+ // Navigation not ready yet
648
+ }
649
+ }, []);
650
+
651
+ // Return props to spread on NavigationContainer
652
+ return {
653
+ ref: navigationRef.current,
654
+ onReady,
655
+ onStateChange: trackNavigationState,
656
+ };
657
+ }
658
+
659
+ /**
660
+ * Setup automatic Expo Router tracking
661
+ *
662
+ * For Expo apps using expo-router - works automatically.
663
+ * For bare React Native apps - use trackNavigationState instead.
664
+ */
665
+ function setupNavigationTracking(): void {
666
+ if (navigationSetupDone) return;
667
+ navigationSetupDone = true;
668
+
669
+ if (__DEV__) {
670
+ logger.debug('Setting up navigation tracking...');
671
+ }
672
+
673
+ // Delay to ensure navigation is initialized - Expo Router needs more time
674
+ // We retry a few times with increasing delays
675
+ let attempts = 0;
676
+ const maxAttempts = 5;
677
+
678
+ const trySetup = () => {
679
+ attempts++;
680
+ if (__DEV__) {
681
+ logger.debug('Navigation setup attempt', attempts, 'of', maxAttempts);
682
+ }
683
+
684
+ const success = trySetupExpoRouter();
685
+
686
+ if (success) {
687
+ if (__DEV__) {
688
+ logger.debug('Expo Router setup: SUCCESS on attempt', attempts);
689
+ }
690
+ } else if (attempts < maxAttempts) {
691
+ // Retry with exponential backoff
692
+ const delay = 200 * attempts; // 200, 400, 600, 800ms
693
+ if (__DEV__) {
694
+ logger.debug('Expo Router not ready, retrying in', delay, 'ms');
695
+ }
696
+ setTimeout(trySetup, delay);
697
+ } else {
698
+ if (__DEV__) {
699
+ logger.debug('Expo Router setup: FAILED after', maxAttempts, 'attempts');
700
+ logger.debug('For manual navigation tracking, use trackNavigationState() on your NavigationContainer');
701
+ }
702
+ }
703
+ };
704
+
705
+ // Start first attempt after 200ms
706
+ setTimeout(trySetup, 200);
707
+ }
708
+
709
+ /**
710
+ * Set up Expo Router auto-tracking by polling the internal router store
711
+ *
712
+ * Supports both expo-router v3 (store.rootState) and v6+ (store.state, store.navigationRef)
713
+ */
714
+ function trySetupExpoRouter(): boolean {
715
+ try {
716
+ const expoRouter = require('expo-router');
717
+ const router = expoRouter.router;
718
+
719
+ if (!router) {
720
+ if (__DEV__) {
721
+ logger.debug('Expo Router: router object not found');
722
+ }
723
+ return false;
724
+ }
725
+
726
+ if (__DEV__) {
727
+ logger.debug('Expo Router: Setting up navigation tracking');
728
+ }
729
+
730
+ const { normalizeScreenName, getScreenNameFromPath } = require('./navigation');
731
+
732
+ // Poll for route changes (expo-router doesn't expose a listener API outside hooks)
733
+ navigationPollingInterval = setInterval(() => {
734
+ try {
735
+ let state = null;
736
+ let stateSource = '';
737
+
738
+ // Method 1: Public accessors on router object
739
+ if (typeof router.getState === 'function') {
740
+ state = router.getState();
741
+ stateSource = 'router.getState()';
742
+ } else if ((router as any).rootState) {
743
+ state = (router as any).rootState;
744
+ stateSource = 'router.rootState';
745
+ }
746
+
747
+ // Method 2: Internal store access (works for both v3 and v6+)
748
+ if (!state) {
749
+ try {
750
+ const storeModule = require('expo-router/build/global-state/router-store');
751
+ if (storeModule?.store) {
752
+ // v6+: store.state or store.navigationRef
753
+ state = storeModule.store.state;
754
+ if (state) stateSource = 'store.state';
755
+
756
+ // v6+: Try navigationRef if state is undefined
757
+ if (!state && storeModule.store.navigationRef?.current) {
758
+ state = storeModule.store.navigationRef.current.getRootState?.();
759
+ if (state) stateSource = 'navigationRef.getRootState()';
760
+ }
761
+
762
+ // v3: store.rootState or store.initialState
763
+ if (!state) {
764
+ state = storeModule.store.rootState || storeModule.store.initialState;
765
+ if (state) stateSource = 'store.rootState/initialState';
766
+ }
767
+ }
768
+ } catch {
769
+ // Store not available
770
+ }
771
+ }
772
+
773
+ // Method 3: Try accessing via a different export path for v6
774
+ if (!state) {
775
+ try {
776
+ const imperative = require('expo-router/build/imperative-api');
777
+ if (imperative?.router) {
778
+ state = imperative.router.getState?.();
779
+ if (state) stateSource = 'imperative-api';
780
+ }
781
+ } catch {
782
+ // Imperative API not available
783
+ }
784
+ }
785
+
786
+ if (state) {
787
+ // Reset error count on success
788
+ navigationPollingErrors = 0;
789
+ const screenName = extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName);
790
+ if (screenName && screenName !== lastDetectedScreen) {
791
+ if (__DEV__) {
792
+ logger.debug('Screen changed:', lastDetectedScreen, '->', screenName, `(source: ${stateSource})`);
793
+ }
794
+ lastDetectedScreen = screenName;
795
+ trackScreen(screenName);
796
+ }
797
+ } else {
798
+ // Track consecutive failures to get state
799
+ navigationPollingErrors++;
800
+ if (__DEV__ && navigationPollingErrors === 1) {
801
+ logger.debug('Expo Router: Could not get navigation state');
802
+ }
803
+ if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
804
+ // Stop polling after too many errors to save CPU
805
+ if (__DEV__) {
806
+ logger.debug('Expo Router: Stopped polling after', MAX_POLLING_ERRORS, 'errors');
807
+ }
808
+ cleanupNavigationTracking();
809
+ }
810
+ }
811
+ } catch (e) {
812
+ // Error - track and potentially stop
813
+ navigationPollingErrors++;
814
+ if (__DEV__ && navigationPollingErrors === 1) {
815
+ logger.debug('Expo Router polling error:', e);
816
+ }
817
+ if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
818
+ cleanupNavigationTracking();
819
+ }
820
+ }
821
+ }, 500); // 500ms polling (reduced from 300ms for lower CPU usage)
822
+
823
+ return true;
824
+ } catch (e) {
825
+ if (__DEV__) {
826
+ logger.debug('Expo Router not available:', e);
827
+ }
828
+ // expo-router not installed
829
+ return false;
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Extract screen name from Expo Router navigation state
835
+ *
836
+ * Handles complex nested structures like Drawer → Tabs → Stack
837
+ * by recursively accumulating segments from each navigation level.
838
+ */
839
+ function extractScreenNameFromRouterState(
840
+ state: any,
841
+ getScreenNameFromPath: (path: string, segments: string[]) => string,
842
+ normalizeScreenName: (name: string) => string,
843
+ accumulatedSegments: string[] = []
844
+ ): string | null {
845
+ if (!state?.routes) return null;
846
+
847
+ const route = state.routes[state.index ?? state.routes.length - 1];
848
+ if (!route) return null;
849
+
850
+ // Add current route name to accumulated segments
851
+ const newSegments = [...accumulatedSegments, route.name];
852
+
853
+ // If this route has nested state, recurse deeper
854
+ if (route.state) {
855
+ return extractScreenNameFromRouterState(
856
+ route.state,
857
+ getScreenNameFromPath,
858
+ normalizeScreenName,
859
+ newSegments
860
+ );
861
+ }
862
+
863
+ // We've reached the deepest level - build the screen name
864
+ // Filter out group markers like (tabs), (main), (auth)
865
+ const cleanSegments = newSegments.filter(s => !s.startsWith('(') && !s.endsWith(')'));
866
+
867
+ // If after filtering we have no segments, use the last meaningful name
868
+ if (cleanSegments.length === 0) {
869
+ // Find the last non-group segment
870
+ for (let i = newSegments.length - 1; i >= 0; i--) {
871
+ const seg = newSegments[i];
872
+ if (seg && !seg.startsWith('(') && !seg.endsWith(')')) {
873
+ cleanSegments.push(seg);
874
+ break;
875
+ }
876
+ }
877
+ }
878
+
879
+ const pathname = '/' + cleanSegments.join('/');
880
+ return getScreenNameFromPath(pathname, newSegments);
881
+ }
882
+
883
+ /**
884
+ * Cleanup navigation tracking
885
+ */
886
+ function cleanupNavigationTracking(): void {
887
+ if (navigationPollingInterval) {
888
+ clearInterval(navigationPollingInterval);
889
+ navigationPollingInterval = null;
890
+ }
891
+ navigationSetupDone = false;
892
+ lastDetectedScreen = '';
893
+ navigationPollingErrors = 0;
894
+ }
895
+
896
+ /**
897
+ * Track a screen view
898
+ * This updates JS metrics AND notifies the native module to send to backend
899
+ */
900
+ export function trackScreen(screenName: string): void {
901
+ if (!isInitialized) {
902
+ if (__DEV__) {
903
+ logger.debug('trackScreen called but not initialized, screen:', screenName);
904
+ }
905
+ return;
906
+ }
907
+
908
+ const previousScreen = currentScreen;
909
+ currentScreen = screenName;
910
+ // Add to screens visited (only track for unique set, avoid large array copies)
911
+ screensVisited.push(screenName);
912
+
913
+ // Update unique screens count
914
+ const uniqueScreens = new Set(screensVisited);
915
+ metrics.uniqueScreensCount = uniqueScreens.size;
916
+
917
+ // Update navigation count
918
+ metrics.navigationCount++;
919
+ metrics.totalEvents++;
920
+
921
+ if (__DEV__) {
922
+ logger.debug('trackScreen:', screenName, '(total screens:', metrics.uniqueScreensCount, ')');
923
+ }
924
+
925
+ // Notify callback
926
+ if (onScreenChange) {
927
+ onScreenChange(screenName, previousScreen);
928
+ }
929
+
930
+ // IMPORTANT: Also notify native module to send to backend
931
+ // This is the key fix - without this, screens don't get recorded!
932
+ try {
933
+ const RejourneyNative = getRejourneyNativeModule();
934
+ if (RejourneyNative?.screenChanged) {
935
+ if (__DEV__) {
936
+ logger.debug('Notifying native screenChanged:', screenName);
937
+ }
938
+ RejourneyNative.screenChanged(screenName).catch((e: Error) => {
939
+ if (__DEV__) {
940
+ logger.debug('Native screenChanged error:', e);
941
+ }
942
+ });
943
+ } else if (__DEV__) {
944
+ logger.debug('Native screenChanged method not available');
945
+ }
946
+ } catch (e) {
947
+ if (__DEV__) {
948
+ logger.debug('trackScreen native call error:', e);
949
+ }
950
+ }
951
+ }
952
+
953
+ // =============================================================================
954
+ // API Metrics Tracking
955
+ // =============================================================================
956
+
957
+ /**
958
+ * Track an API request with timing data
959
+ */
960
+ export function trackAPIRequest(
961
+ success: boolean,
962
+ _statusCode: number,
963
+ durationMs: number = 0,
964
+ responseBytes: number = 0
965
+ ): void {
966
+ if (!isInitialized) return;
967
+
968
+ metrics.apiTotalCount++;
969
+
970
+ // Accumulate timing and size for avg calculation
971
+ if (durationMs > 0) {
972
+ metrics.netTotalDurationMs += durationMs;
973
+ }
974
+ if (responseBytes > 0) {
975
+ metrics.netTotalBytes += responseBytes;
976
+ }
977
+
978
+ if (success) {
979
+ metrics.apiSuccessCount++;
980
+ } else {
981
+ metrics.apiErrorCount++;
982
+
983
+ // API errors also count toward error count for UX score
984
+ metrics.errorCount++;
985
+ }
986
+ }
987
+
988
+ // =============================================================================
989
+ // Session Metrics
990
+ // =============================================================================
991
+
992
+ /**
993
+ * Create empty metrics object
994
+ */
995
+ function createEmptyMetrics(): SessionMetrics {
996
+ return {
997
+ totalEvents: 0,
998
+ touchCount: 0,
999
+ scrollCount: 0,
1000
+ gestureCount: 0,
1001
+ inputCount: 0,
1002
+ navigationCount: 0,
1003
+ errorCount: 0,
1004
+ rageTapCount: 0,
1005
+ apiSuccessCount: 0,
1006
+ apiErrorCount: 0,
1007
+ apiTotalCount: 0,
1008
+ netTotalDurationMs: 0,
1009
+ netTotalBytes: 0,
1010
+ screensVisited: [],
1011
+ uniqueScreensCount: 0,
1012
+ interactionScore: 100,
1013
+ explorationScore: 100,
1014
+ uxScore: 100,
1015
+ };
1016
+ }
1017
+
1018
+ /**
1019
+ * Track a scroll event
1020
+ */
1021
+ export function trackScroll(): void {
1022
+ if (!isInitialized) return;
1023
+ metrics.scrollCount++;
1024
+ metrics.totalEvents++;
1025
+ }
1026
+
1027
+ /**
1028
+ * Track a gesture event
1029
+ */
1030
+ export function trackGesture(): void {
1031
+ if (!isInitialized) return;
1032
+ metrics.gestureCount++;
1033
+ metrics.totalEvents++;
1034
+ }
1035
+
1036
+ /**
1037
+ * Track an input event (keyboard)
1038
+ */
1039
+ export function trackInput(): void {
1040
+ if (!isInitialized) return;
1041
+ metrics.inputCount++;
1042
+ metrics.totalEvents++;
1043
+ }
1044
+
1045
+ /**
1046
+ * Get current session metrics
1047
+ */
1048
+ export function getSessionMetrics(): SessionMetrics & { netAvgDurationMs: number } {
1049
+ // Calculate scores before returning
1050
+ calculateScores();
1051
+
1052
+ // Compute average API response time
1053
+ const netAvgDurationMs = metrics.apiTotalCount > 0
1054
+ ? Math.round(metrics.netTotalDurationMs / metrics.apiTotalCount)
1055
+ : 0;
1056
+
1057
+ // Lazily populate screensVisited only when metrics are retrieved
1058
+ // This avoids expensive array copies on every screen change
1059
+ return {
1060
+ ...metrics,
1061
+ screensVisited: [...screensVisited], // Only copy here when needed
1062
+ netAvgDurationMs,
1063
+ };
1064
+ }
1065
+
1066
+ /**
1067
+ * Calculate session scores
1068
+ */
1069
+ function calculateScores(): void {
1070
+ // Interaction Score (0-100)
1071
+ // Based on total interactions normalized to a baseline
1072
+ const totalInteractions =
1073
+ metrics.touchCount +
1074
+ metrics.scrollCount +
1075
+ metrics.gestureCount +
1076
+ metrics.inputCount;
1077
+
1078
+ // Assume 50 interactions is "average" for a session
1079
+ const avgInteractions = 50;
1080
+ metrics.interactionScore = Math.min(100, Math.round((totalInteractions / avgInteractions) * 100));
1081
+
1082
+ // Exploration Score (0-100)
1083
+ // Based on unique screens visited
1084
+ // Assume 5 screens is "average" exploration
1085
+ const avgScreens = 5;
1086
+ metrics.explorationScore = Math.min(100, Math.round((metrics.uniqueScreensCount / avgScreens) * 100));
1087
+
1088
+ // UX Score (0-100)
1089
+ // Starts at 100, deducts for issues
1090
+ let uxScore = 100;
1091
+
1092
+ // Deduct for errors
1093
+ uxScore -= Math.min(30, metrics.errorCount * 15); // Max 30 point deduction
1094
+
1095
+ // Deduct for rage taps
1096
+ uxScore -= Math.min(24, metrics.rageTapCount * 8); // Max 24 point deduction
1097
+
1098
+ // Deduct for API errors
1099
+ uxScore -= Math.min(20, metrics.apiErrorCount * 10); // Max 20 point deduction
1100
+
1101
+ // Bonus for completing funnel (if screens > 3)
1102
+ if (metrics.uniqueScreensCount >= 3) {
1103
+ uxScore += 5;
1104
+ }
1105
+
1106
+ metrics.uxScore = Math.max(0, Math.min(100, uxScore));
1107
+ }
1108
+
1109
+ /**
1110
+ * Reset metrics for new session
1111
+ */
1112
+ export function resetMetrics(): void {
1113
+ metrics = createEmptyMetrics();
1114
+ screensVisited = [];
1115
+ currentScreen = '';
1116
+ tapHead = 0;
1117
+ tapCount = 0;
1118
+ sessionStartTime = Date.now();
1119
+ }
1120
+
1121
+ // =============================================================================
1122
+ // Session duration helpers
1123
+ // =============================================================================
1124
+
1125
+ /** Clamp and set max session duration in minutes (1–10). Defaults to 10. */
1126
+ export function setMaxSessionDurationMinutes(minutes?: number): void {
1127
+ const clampedMinutes = Math.min(10, Math.max(1, minutes ?? 10));
1128
+ maxSessionDurationMs = clampedMinutes * 60 * 1000;
1129
+ }
1130
+
1131
+ /** Returns true if the current session exceeded the configured max duration. */
1132
+ export function hasExceededMaxSessionDuration(): boolean {
1133
+ if (!sessionStartTime) return false;
1134
+ return Date.now() - sessionStartTime >= maxSessionDurationMs;
1135
+ }
1136
+
1137
+ /** Returns remaining milliseconds until the session should stop. */
1138
+ export function getRemainingSessionDurationMs(): number {
1139
+ if (!sessionStartTime) return maxSessionDurationMs;
1140
+ const remaining = maxSessionDurationMs - (Date.now() - sessionStartTime);
1141
+ return Math.max(0, remaining);
1142
+ }
1143
+
1144
+ // =============================================================================
1145
+ // Device Info Collection
1146
+ // =============================================================================
1147
+
1148
+ /**
1149
+ * Collect device information
1150
+ */
1151
+ export function collectDeviceInfo(): DeviceInfo {
1152
+ const Dimensions = getDimensions();
1153
+ const Platform = getPlatform();
1154
+ const NativeModules = getNativeModules();
1155
+
1156
+ // Default values if react-native isn't available
1157
+ let width = 0, height = 0, scale = 1;
1158
+
1159
+ if (Dimensions) {
1160
+ const windowDims = Dimensions.get('window');
1161
+ const screenDims = Dimensions.get('screen');
1162
+ width = windowDims?.width || 0;
1163
+ height = windowDims?.height || 0;
1164
+ scale = screenDims?.scale || 1;
1165
+ }
1166
+
1167
+ // Get device model - this varies by platform
1168
+ let model = 'Unknown';
1169
+ let manufacturer: string | undefined;
1170
+ let osVersion = 'Unknown';
1171
+ let appVersion: string | undefined;
1172
+ let appId: string | undefined;
1173
+ let locale: string | undefined;
1174
+ let timezone: string | undefined;
1175
+
1176
+ try {
1177
+ // Try to get from react-native-device-info if available
1178
+ // This is optional - falls back to basic info if not installed
1179
+ const DeviceInfo = require('react-native-device-info');
1180
+ model = DeviceInfo.getModel?.() || model;
1181
+ manufacturer = DeviceInfo.getBrand?.() || undefined;
1182
+ osVersion = DeviceInfo.getSystemVersion?.() || osVersion;
1183
+ appVersion = DeviceInfo.getVersion?.() || undefined;
1184
+ appId = DeviceInfo.getBundleId?.() || undefined;
1185
+ locale = DeviceInfo.getDeviceLocale?.() || undefined;
1186
+ timezone = DeviceInfo.getTimezone?.() || undefined;
1187
+ } catch {
1188
+ // react-native-device-info not installed, try Expo packages
1189
+ try {
1190
+ // Try expo-application for app version/id
1191
+ const Application = require('expo-application');
1192
+ appVersion = Application.nativeApplicationVersion || Application.applicationVersion || undefined;
1193
+ appId = Application.applicationId || undefined;
1194
+ } catch {
1195
+ // expo-application not available
1196
+ }
1197
+
1198
+ try {
1199
+ // Try expo-constants for additional info
1200
+ const Constants = require('expo-constants');
1201
+ const expoConfig = Constants.expoConfig || Constants.manifest2?.extra?.expoClient || Constants.manifest;
1202
+ if (!appVersion && expoConfig?.version) {
1203
+ appVersion = expoConfig.version;
1204
+ }
1205
+ if (!appId && (expoConfig?.ios?.bundleIdentifier || expoConfig?.android?.package)) {
1206
+ appId = Platform?.OS === 'ios'
1207
+ ? expoConfig?.ios?.bundleIdentifier
1208
+ : expoConfig?.android?.package;
1209
+ }
1210
+ } catch {
1211
+ // expo-constants not available
1212
+ }
1213
+
1214
+ // Fall back to basic platform info
1215
+ if (Platform?.OS === 'ios') {
1216
+ // Get basic info from constants
1217
+ const PlatformConstants = NativeModules?.PlatformConstants;
1218
+ osVersion = Platform.Version?.toString() || osVersion;
1219
+ model = PlatformConstants?.interfaceIdiom === 'pad' ? 'iPad' : 'iPhone';
1220
+ } else if (Platform?.OS === 'android') {
1221
+ osVersion = Platform.Version?.toString() || osVersion;
1222
+ model = 'Android Device';
1223
+ }
1224
+ }
1225
+
1226
+ // Get timezone
1227
+ if (!timezone) {
1228
+ try {
1229
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
1230
+ } catch {
1231
+ timezone = undefined;
1232
+ }
1233
+ }
1234
+
1235
+ // Get locale
1236
+ if (!locale) {
1237
+ try {
1238
+ locale = Intl.DateTimeFormat().resolvedOptions().locale;
1239
+ } catch {
1240
+ locale = undefined;
1241
+ }
1242
+ }
1243
+
1244
+ return {
1245
+ model,
1246
+ manufacturer,
1247
+ os: (Platform?.OS || 'ios') as 'ios' | 'android',
1248
+ osVersion,
1249
+ screenWidth: Math.round(width),
1250
+ screenHeight: Math.round(height),
1251
+ pixelRatio: scale,
1252
+ appVersion,
1253
+ appId,
1254
+ locale,
1255
+ timezone,
1256
+ };
1257
+ }
1258
+
1259
+ // =============================================================================
1260
+ // Anonymous ID Generation
1261
+ // =============================================================================
1262
+
1263
+ // Storage key for anonymous ID
1264
+ const ANONYMOUS_ID_KEY = '@rejourney_anonymous_id';
1265
+
1266
+ /**
1267
+ * Generate a persistent anonymous ID
1268
+ */
1269
+ function generateAnonymousId(): string {
1270
+ const timestamp = Date.now().toString(36);
1271
+ const random = Math.random().toString(36).substring(2, 15);
1272
+ return `anon_${timestamp}_${random}`;
1273
+ }
1274
+
1275
+ /**
1276
+ * Initialize anonymous ID - tries to load from storage, generates new if not found
1277
+ * This is called internally and runs asynchronously
1278
+ */
1279
+ async function initAnonymousId(): Promise<void> {
1280
+ try {
1281
+ // Try to load from AsyncStorage
1282
+ const AsyncStorage = require('@react-native-async-storage/async-storage').default;
1283
+ const storedId = await AsyncStorage.getItem(ANONYMOUS_ID_KEY);
1284
+
1285
+ if (storedId) {
1286
+ anonymousId = storedId;
1287
+ } else {
1288
+ // Generate new ID and persist
1289
+ anonymousId = generateAnonymousId();
1290
+ await AsyncStorage.setItem(ANONYMOUS_ID_KEY, anonymousId);
1291
+ }
1292
+ } catch {
1293
+ // AsyncStorage not available or error - just generate without persistence
1294
+ if (!anonymousId) {
1295
+ anonymousId = generateAnonymousId();
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ /**
1301
+ * Get the anonymous ID (synchronous - returns generated ID immediately)
1302
+ * For persistent ID, call initAnonymousId() first
1303
+ */
1304
+ export function getAnonymousId(): string {
1305
+ if (!anonymousId) {
1306
+ anonymousId = generateAnonymousId();
1307
+ // Try to persist asynchronously (fire and forget)
1308
+ initAnonymousId().catch(() => { });
1309
+ }
1310
+ return anonymousId;
1311
+ }
1312
+
1313
+ /**
1314
+ * Ensure a stable, persisted anonymous/device ID is available.
1315
+ * Returns the stored ID if present, otherwise generates and persists one.
1316
+ */
1317
+ export async function ensurePersistentAnonymousId(): Promise<string> {
1318
+ if (anonymousId) return anonymousId;
1319
+ if (!anonymousIdPromise) {
1320
+ anonymousIdPromise = (async () => {
1321
+ await initAnonymousId();
1322
+ if (!anonymousId) {
1323
+ anonymousId = generateAnonymousId();
1324
+ }
1325
+ return anonymousId;
1326
+ })();
1327
+ }
1328
+ return anonymousIdPromise;
1329
+ }
1330
+
1331
+ /**
1332
+ * Load anonymous ID from persistent storage
1333
+ * Call this at app startup for best results
1334
+ */
1335
+ export async function loadAnonymousId(): Promise<string> {
1336
+ await initAnonymousId();
1337
+ return anonymousId || generateAnonymousId();
1338
+ }
1339
+
1340
+ /**
1341
+ * Set a custom anonymous ID (e.g., from persistent storage)
1342
+ */
1343
+ export function setAnonymousId(id: string): void {
1344
+ anonymousId = id;
1345
+ // Try to persist asynchronously
1346
+ try {
1347
+ const AsyncStorage = require('@react-native-async-storage/async-storage').default;
1348
+ AsyncStorage.setItem(ANONYMOUS_ID_KEY, id).catch(() => { });
1349
+ } catch {
1350
+ // Ignore if AsyncStorage not available
1351
+ }
1352
+ }
1353
+
1354
+ // =============================================================================
1355
+ // Exports
1356
+ // =============================================================================
1357
+
1358
+ export default {
1359
+ init: initAutoTracking,
1360
+ cleanup: cleanupAutoTracking,
1361
+ trackTap,
1362
+ trackScroll,
1363
+ trackGesture,
1364
+ trackInput,
1365
+ trackScreen,
1366
+ trackAPIRequest,
1367
+ captureError,
1368
+ getMetrics: getSessionMetrics,
1369
+ resetMetrics,
1370
+ collectDeviceInfo,
1371
+ getAnonymousId,
1372
+ setAnonymousId,
1373
+ };