@datalyr/react-native 1.4.8 → 1.5.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.
package/README.md CHANGED
@@ -19,6 +19,7 @@ Mobile analytics and attribution SDK for React Native and Expo. Track events, id
19
19
  - [Attribution](#attribution)
20
20
  - [Automatic Capture](#automatic-capture)
21
21
  - [Deferred Deep Links](#deferred-deep-links)
22
+ - [Web-to-App Attribution](#web-to-app-attribution)
22
23
  - [Event Queue](#event-queue)
23
24
  - [Auto Events](#auto-events)
24
25
  - [SKAdNetwork](#skadnetwork)
@@ -356,6 +357,23 @@ if (deferred) {
356
357
  }
357
358
  ```
358
359
 
360
+ ### Web-to-App Attribution
361
+
362
+ Automatically recover attribution from a web prelander when users install the app from an ad.
363
+
364
+ **How it works:**
365
+ - **Android**: Attribution params are passed through the Play Store `referrer` URL parameter (set by the web SDK's `trackAppDownloadClick()`). The mobile SDK reads these via the Play Install Referrer API — deterministic, ~95% accuracy.
366
+ - **iOS**: On first install, the SDK calls the Datalyr API to match the device's IP against recent `$app_download_click` web events within 24 hours — ~90%+ accuracy for immediate installs.
367
+
368
+ No additional mobile code is needed. Attribution is recovered automatically during `initialize()` on first install, before the `app_install` event fires.
369
+
370
+ After a match, the SDK:
371
+ 1. Merges web attribution (click IDs, UTMs, cookies) into the mobile session
372
+ 2. Tracks a `$web_attribution_matched` event for analytics
373
+ 3. All subsequent events (including purchases) carry the matched attribution
374
+
375
+ **Fallback:** If IP matching misses (e.g., VPN toggle during install), email-based attribution is still recovered when `identify()` is called with the user's email.
376
+
359
377
  ### Manual Attribution
360
378
 
361
379
  Set attribution programmatically:
@@ -478,13 +496,25 @@ await Datalyr.initialize({
478
496
  await Datalyr.initialize({
479
497
  apiKey: 'dk_your_api_key',
480
498
  tiktok: {
481
- appId: 'your_app_id',
482
- tiktokAppId: '7123456789',
499
+ appId: 'your_app_id', // Events API App ID
500
+ tiktokAppId: '7123456789', // TikTok App ID (Developer Portal)
501
+ accessToken: 'your_access_token', // Events API Access Token
483
502
  enableAppEvents: true,
484
503
  },
485
504
  });
486
505
  ```
487
506
 
507
+ **Where to find your TikTok credentials:**
508
+
509
+ | Credential | Where to get it |
510
+ |------------|----------------|
511
+ | `tiktokAppId` | [TikTok Developer Portal](https://developers.tiktok.com) → Your App → App ID |
512
+ | `appId` | TikTok Business Center → Assets → Events → Your App → App ID |
513
+ | `accessToken` | TikTok Business Center → Assets → Events → Your App → Settings → Access Token |
514
+
515
+ > **Note:** The `accessToken` enables client-side TikTok SDK features (enhanced attribution, real-time event forwarding). Without it, events are still tracked server-side via Datalyr postbacks — you'll see a warning in debug mode.
516
+ ```
517
+
488
518
  ### Apple Search Ads
489
519
 
490
520
  Attribution for users who install from Apple Search Ads (iOS 14.3+). Automatically fetched on initialization.
@@ -667,12 +697,17 @@ Check status: `Datalyr.getPlatformIntegrationStatus()`
667
697
 
668
698
  ### TikTok SDK Not Working
669
699
 
700
+ 1. Make sure you have all three TikTok credentials (see [TikTok setup](#tiktok))
701
+ 2. The `accessToken` is required for client-side SDK — without it, you'll see a warning but server-side tracking still works
702
+ 3. Check status: `Datalyr.getPlatformIntegrationStatus()`
703
+
670
704
  ```typescript
671
705
  await Datalyr.initialize({
672
706
  apiKey: 'dk_your_api_key',
673
707
  tiktok: {
674
708
  appId: 'your_app_id',
675
709
  tiktokAppId: '7123456789012345',
710
+ accessToken: 'your_access_token',
676
711
  },
677
712
  });
678
713
  ```
@@ -51,4 +51,7 @@ dependencies {
51
51
 
52
52
  // Google Play Install Referrer
53
53
  implementation 'com.android.installreferrer:installreferrer:2.2'
54
+
55
+ // Google Advertising ID (GAID) for attribution
56
+ implementation 'com.google.android.gms:play-services-ads-identifier:18.1.0'
54
57
  }
@@ -26,6 +26,9 @@ import com.tiktok.TikTokBusinessSdk.TTConfig;
26
26
  import com.tiktok.appevents.TikTokAppEvent;
27
27
  import com.tiktok.appevents.TikTokAppEventLogger;
28
28
 
29
+ import com.google.android.gms.ads.identifier.AdvertisingIdClient;
30
+ import com.google.android.gms.common.GooglePlayServicesNotAvailableException;
31
+
29
32
  import java.math.BigDecimal;
30
33
  import java.util.Currency;
31
34
  import java.util.HashMap;
@@ -391,6 +394,47 @@ public class DatalyrNativeModule extends ReactContextBaseJavaModule {
391
394
  promise.resolve(result);
392
395
  }
393
396
 
397
+ // ============================================================================
398
+ // Advertiser Info (GAID on Android)
399
+ // ============================================================================
400
+
401
+ @ReactMethod
402
+ public void getAdvertiserInfo(Promise promise) {
403
+ // GAID must be fetched on a background thread
404
+ new Thread(() -> {
405
+ try {
406
+ WritableMap result = Arguments.createMap();
407
+
408
+ // Fetch Google Advertising ID
409
+ try {
410
+ AdvertisingIdClient.Info adInfo = AdvertisingIdClient.getAdvertisingIdInfo(reactContext.getApplicationContext());
411
+ boolean limitAdTracking = adInfo.isLimitAdTrackingEnabled();
412
+ result.putBoolean("advertiser_tracking_enabled", !limitAdTracking);
413
+ result.putInt("att_status", limitAdTracking ? 2 : 3); // 2=denied, 3=authorized
414
+
415
+ if (!limitAdTracking && adInfo.getId() != null) {
416
+ result.putString("gaid", adInfo.getId());
417
+ }
418
+ } catch (GooglePlayServicesNotAvailableException e) {
419
+ // Google Play Services not available (e.g., Huawei devices)
420
+ result.putInt("att_status", 3);
421
+ result.putBoolean("advertiser_tracking_enabled", true);
422
+ Log.d(TAG, "Google Play Services not available for GAID");
423
+ } catch (Exception e) {
424
+ // Fallback — GAID not available but not blocking
425
+ result.putInt("att_status", 3);
426
+ result.putBoolean("advertiser_tracking_enabled", true);
427
+ Log.d(TAG, "GAID not available: " + e.getMessage());
428
+ }
429
+
430
+ promise.resolve(result);
431
+ } catch (Exception e) {
432
+ Log.e(TAG, "Failed to get advertiser info: " + e.getMessage());
433
+ promise.resolve(null);
434
+ }
435
+ }).start();
436
+ }
437
+
394
438
  // ============================================================================
395
439
  // Helper Methods
396
440
  // ============================================================================
@@ -2,6 +2,8 @@ import ExpoModulesCore
2
2
  import FBSDKCoreKit
3
3
  import TikTokBusinessSDK
4
4
  import AdServices
5
+ import AppTrackingTransparency
6
+ import AdSupport
5
7
 
6
8
  public class DatalyrNativeModule: Module {
7
9
  private var tiktokInitialized = false
@@ -280,6 +282,44 @@ public class DatalyrNativeModule: Module {
280
282
  ])
281
283
  }
282
284
 
285
+ // MARK: - Advertiser Info (IDFA, IDFV, ATT Status)
286
+
287
+ AsyncFunction("getAdvertiserInfo") { (promise: Promise) in
288
+ var result: [String: Any] = [:]
289
+
290
+ // IDFV is always available
291
+ if let idfv = UIDevice.current.identifierForVendor?.uuidString {
292
+ result["idfv"] = idfv
293
+ }
294
+
295
+ // ATT status
296
+ if #available(iOS 14, *) {
297
+ let status = ATTrackingManager.trackingAuthorizationStatus
298
+ result["att_status"] = status.rawValue
299
+ result["advertiser_tracking_enabled"] = status == .authorized
300
+
301
+ // IDFA only if ATT authorized
302
+ if status == .authorized {
303
+ let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString
304
+ let zeroUUID = "00000000-0000-0000-0000-000000000000"
305
+ if idfa != zeroUUID {
306
+ result["idfa"] = idfa
307
+ }
308
+ }
309
+ } else {
310
+ // Pre-iOS 14, tracking allowed by default
311
+ result["att_status"] = 3 // .authorized equivalent
312
+ result["advertiser_tracking_enabled"] = true
313
+ let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString
314
+ let zeroUUID = "00000000-0000-0000-0000-000000000000"
315
+ if idfa != zeroUUID {
316
+ result["idfa"] = idfa
317
+ }
318
+ }
319
+
320
+ promise.resolve(result)
321
+ }
322
+
283
323
  // MARK: - Apple Search Ads Attribution
284
324
 
285
325
  AsyncFunction("getAppleSearchAdsAttribution") { (promise: Promise) in
@@ -9,6 +9,7 @@ export declare class DatalyrSDK {
9
9
  private autoEventsManager;
10
10
  private appStateSubscription;
11
11
  private networkStatusUnsubscribe;
12
+ private cachedAdvertiserInfo;
12
13
  private static conversionEncoder?;
13
14
  private static debugEnabled;
14
15
  constructor();
@@ -33,6 +34,13 @@ export declare class DatalyrSDK {
33
34
  * Called automatically during identify() if email is provided
34
35
  */
35
36
  private fetchAndMergeWebAttribution;
37
+ /**
38
+ * Fetch deferred web attribution on first app install.
39
+ * Uses IP-based matching (iOS) or Play Store referrer (Android) to recover
40
+ * attribution data (fbclid, utm_*, etc.) from a prelander web visit.
41
+ * Called automatically during initialize() when a fresh install is detected.
42
+ */
43
+ private fetchDeferredWebAttribution;
36
44
  /**
37
45
  * Alias a user (connect anonymous user to known user)
38
46
  */
@@ -8,12 +8,14 @@ import { AutoEventsManager } from './auto-events';
8
8
  import { ConversionValueEncoder, ConversionTemplates } from './ConversionValueEncoder';
9
9
  import { SKAdNetworkBridge } from './native/SKAdNetworkBridge';
10
10
  import { metaIntegration, tiktokIntegration, appleSearchAdsIntegration, playInstallReferrerIntegration } from './integrations';
11
+ import { AdvertiserInfoBridge } from './native/DatalyrNativeBridge';
11
12
  import { networkStatusManager } from './network-status';
12
13
  export class DatalyrSDK {
13
14
  constructor() {
14
15
  this.autoEventsManager = null;
15
16
  this.appStateSubscription = null;
16
17
  this.networkStatusUnsubscribe = null;
18
+ this.cachedAdvertiserInfo = null;
17
19
  // Initialize state with defaults
18
20
  this.state = {
19
21
  initialized: false,
@@ -26,9 +28,11 @@ export class DatalyrSDK {
26
28
  maxRetries: 3,
27
29
  retryDelay: 1000,
28
30
  batchSize: 10,
29
- flushInterval: 10000,
31
+ flushInterval: 30000,
30
32
  maxQueueSize: 100,
31
33
  respectDoNotTrack: true,
34
+ enableAutoEvents: true,
35
+ enableAttribution: true,
32
36
  },
33
37
  visitorId: '',
34
38
  anonymousId: '', // Persistent anonymous identifier
@@ -166,6 +170,13 @@ export class DatalyrSDK {
166
170
  }
167
171
  // Wait for all platform integrations to complete
168
172
  await Promise.all(platformInitPromises);
173
+ // Cache advertiser info (IDFA/GAID, ATT status) once at init to avoid per-event native bridge calls
174
+ try {
175
+ this.cachedAdvertiserInfo = await AdvertiserInfoBridge.getAdvertiserInfo();
176
+ }
177
+ catch (error) {
178
+ errorLog('Failed to cache advertiser info:', error);
179
+ }
169
180
  debugLog('Platform integrations initialized', {
170
181
  meta: metaIntegration.isAvailable(),
171
182
  tiktok: tiktokIntegration.isAvailable(),
@@ -176,10 +187,15 @@ export class DatalyrSDK {
176
187
  this.state.initialized = true;
177
188
  // Check for app install (after SDK is marked as initialized)
178
189
  if (attributionManager.isInstall()) {
190
+ // iOS: Attempt deferred web-to-app attribution via IP matching before tracking install
191
+ // Android: Play Store referrer is handled by playInstallReferrerIntegration
192
+ if (Platform.OS === 'ios') {
193
+ await this.fetchDeferredWebAttribution();
194
+ }
179
195
  const installData = await attributionManager.trackInstall();
180
196
  await this.track('app_install', {
181
197
  platform: Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : 'android',
182
- sdk_version: '1.0.2',
198
+ sdk_version: '1.4.9',
183
199
  ...installData,
184
200
  });
185
201
  }
@@ -357,6 +373,77 @@ export class DatalyrSDK {
357
373
  // Non-blocking - continue even if attribution fetch fails
358
374
  }
359
375
  }
376
+ /**
377
+ * Fetch deferred web attribution on first app install.
378
+ * Uses IP-based matching (iOS) or Play Store referrer (Android) to recover
379
+ * attribution data (fbclid, utm_*, etc.) from a prelander web visit.
380
+ * Called automatically during initialize() when a fresh install is detected.
381
+ */
382
+ async fetchDeferredWebAttribution() {
383
+ var _a;
384
+ if (!((_a = this.state.config) === null || _a === void 0 ? void 0 : _a.apiKey)) {
385
+ debugLog('API key not available for deferred attribution fetch');
386
+ return;
387
+ }
388
+ try {
389
+ debugLog('Fetching deferred web attribution via IP matching...');
390
+ const baseUrl = this.state.config.endpoint || 'https://api.datalyr.com';
391
+ const controller = new AbortController();
392
+ const timeout = setTimeout(() => controller.abort(), 10000);
393
+ const response = await fetch(`${baseUrl}/attribution/deferred-lookup`, {
394
+ method: 'POST',
395
+ headers: {
396
+ 'Content-Type': 'application/json',
397
+ 'X-Datalyr-API-Key': this.state.config.apiKey,
398
+ },
399
+ body: JSON.stringify({ platform: Platform.OS }),
400
+ signal: controller.signal,
401
+ });
402
+ clearTimeout(timeout);
403
+ if (!response.ok) {
404
+ debugLog('Deferred attribution lookup failed:', response.status);
405
+ return;
406
+ }
407
+ const result = await response.json();
408
+ if (!result.found || !result.attribution) {
409
+ debugLog('No deferred web attribution found for this IP');
410
+ return;
411
+ }
412
+ const webAttribution = result.attribution;
413
+ debugLog('Deferred web attribution found:', {
414
+ visitor_id: webAttribution.visitor_id,
415
+ has_fbclid: !!webAttribution.fbclid,
416
+ has_gclid: !!webAttribution.gclid,
417
+ utm_source: webAttribution.utm_source,
418
+ });
419
+ // Merge web attribution into current session
420
+ attributionManager.mergeWebAttribution(webAttribution);
421
+ // Track match event for analytics
422
+ await this.track('$web_attribution_matched', {
423
+ web_visitor_id: webAttribution.visitor_id,
424
+ web_user_id: webAttribution.user_id,
425
+ fbclid: webAttribution.fbclid,
426
+ gclid: webAttribution.gclid,
427
+ ttclid: webAttribution.ttclid,
428
+ gbraid: webAttribution.gbraid,
429
+ wbraid: webAttribution.wbraid,
430
+ fbp: webAttribution.fbp,
431
+ fbc: webAttribution.fbc,
432
+ utm_source: webAttribution.utm_source,
433
+ utm_medium: webAttribution.utm_medium,
434
+ utm_campaign: webAttribution.utm_campaign,
435
+ utm_content: webAttribution.utm_content,
436
+ utm_term: webAttribution.utm_term,
437
+ web_timestamp: webAttribution.timestamp,
438
+ match_method: 'ip',
439
+ });
440
+ debugLog('Successfully merged deferred web attribution');
441
+ }
442
+ catch (error) {
443
+ errorLog('Error fetching deferred web attribution:', error);
444
+ // Non-blocking - email-based fallback will catch this on identify()
445
+ }
446
+ }
360
447
  /**
361
448
  * Alias a user (connect anonymous user to known user)
362
449
  */
@@ -760,6 +847,13 @@ export class DatalyrSDK {
760
847
  }
761
848
  metaIntegration.updateTrackingAuthorization(enabled);
762
849
  tiktokIntegration.updateTrackingAuthorization(enabled);
850
+ // Refresh cached advertiser info after ATT status change
851
+ try {
852
+ this.cachedAdvertiserInfo = await AdvertiserInfoBridge.getAdvertiserInfo();
853
+ }
854
+ catch (error) {
855
+ errorLog('Failed to refresh advertiser info:', error);
856
+ }
763
857
  // Track ATT status event
764
858
  await this.track('$att_status', {
765
859
  authorized: enabled,
@@ -835,6 +929,8 @@ export class DatalyrSDK {
835
929
  asa_conversion_type: asaAttribution.conversionType,
836
930
  asa_country_or_region: asaAttribution.countryOrRegion,
837
931
  } : {};
932
+ // Use cached advertiser info (IDFA/GAID, ATT status) — cached at init, refreshed on ATT change
933
+ const advertiserInfo = this.cachedAdvertiserInfo;
838
934
  const payload = {
839
935
  workspaceId: this.state.config.workspaceId || 'mobile_sdk',
840
936
  visitorId: this.state.visitorId,
@@ -852,8 +948,24 @@ export class DatalyrSDK {
852
948
  device_model: deviceInfo.model,
853
949
  app_version: deviceInfo.appVersion,
854
950
  app_build: deviceInfo.buildNumber,
951
+ app_name: deviceInfo.bundleId, // Best available app name
952
+ app_namespace: deviceInfo.bundleId,
953
+ screen_width: deviceInfo.screenWidth,
954
+ screen_height: deviceInfo.screenHeight,
955
+ locale: deviceInfo.locale,
956
+ timezone: deviceInfo.timezone,
957
+ carrier: deviceInfo.carrier,
855
958
  network_type: getNetworkType(),
856
959
  timestamp: Date.now(),
960
+ sdk_version: '1.4.9',
961
+ // Advertiser data (IDFA/GAID, ATT status) for Meta CAPI / TikTok Events API
962
+ ...(advertiserInfo ? {
963
+ idfa: advertiserInfo.idfa,
964
+ idfv: advertiserInfo.idfv,
965
+ gaid: advertiserInfo.gaid,
966
+ att_status: advertiserInfo.att_status,
967
+ advertiser_tracking_enabled: advertiserInfo.advertiser_tracking_enabled,
968
+ } : {}),
857
969
  // Attribution data
858
970
  ...attributionData,
859
971
  // Apple Search Ads attribution
@@ -216,7 +216,7 @@ export const createEventQueue = (httpClient, config) => {
216
216
  const defaultConfig = {
217
217
  maxQueueSize: 100,
218
218
  batchSize: 10,
219
- flushInterval: 10000, // 10 seconds
219
+ flushInterval: 30000, // 30 seconds — matches SDK constructor defaults and docs
220
220
  maxRetryCount: 3,
221
221
  };
222
222
  return new EventQueue(httpClient, { ...defaultConfig, ...config });
@@ -45,7 +45,7 @@ export class HttpClient {
45
45
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
46
46
  const headers = {
47
47
  'Content-Type': 'application/json',
48
- 'User-Agent': `@datalyr/react-native/1.0.2`,
48
+ 'User-Agent': `@datalyr/react-native/1.4.8`,
49
49
  };
50
50
  // Server-side tracking uses X-API-Key header
51
51
  if (this.config.useServerTracking !== false) {
@@ -143,7 +143,7 @@ export class HttpClient {
143
143
  return {
144
144
  event: payload.eventName,
145
145
  userId: payload.userId || payload.visitorId,
146
- anonymousId: payload.visitorId,
146
+ anonymousId: payload.anonymousId || payload.visitorId,
147
147
  properties: {
148
148
  ...payload.eventData,
149
149
  sessionId: payload.sessionId,
@@ -152,7 +152,7 @@ export class HttpClient {
152
152
  },
153
153
  context: {
154
154
  library: '@datalyr/react-native',
155
- version: '1.0.5',
155
+ version: '1.4.8',
156
156
  source: 'mobile_app', // Explicitly set source for mobile
157
157
  userProperties: payload.userProperties,
158
158
  },
@@ -62,9 +62,12 @@ export class TikTokIntegration {
62
62
  this.initialized = true;
63
63
  this.log(`TikTok SDK initialized with App ID: ${config.tiktokAppId}`);
64
64
  }
65
+ else {
66
+ console.warn('[Datalyr/TikTok] TikTok SDK not initialized (accessToken may be missing). Events will still be sent server-side via Datalyr postbacks.');
67
+ }
65
68
  }
66
69
  catch (error) {
67
- this.logError('Failed to initialize TikTok SDK:', error);
70
+ console.warn('[Datalyr/TikTok] TikTok SDK init failed. Events will still be sent server-side via Datalyr postbacks.', error);
68
71
  }
69
72
  }
70
73
  /**
@@ -78,6 +78,21 @@ export declare const AppleSearchAdsNativeBridge: {
78
78
  */
79
79
  getAttribution(): Promise<AppleSearchAdsAttribution | null>;
80
80
  };
81
+ export interface AdvertiserInfo {
82
+ idfa?: string;
83
+ idfv?: string;
84
+ gaid?: string;
85
+ att_status: number;
86
+ advertiser_tracking_enabled: boolean;
87
+ }
88
+ export declare const AdvertiserInfoBridge: {
89
+ /**
90
+ * Get advertiser info (IDFA, IDFV, ATT status)
91
+ * IDFA is only available when ATT is authorized (iOS 14+)
92
+ * IDFV is always available on iOS
93
+ */
94
+ getAdvertiserInfo(): Promise<AdvertiserInfo | null>;
95
+ };
81
96
  export declare const PlayInstallReferrerNativeBridge: {
82
97
  /**
83
98
  * Check if Play Install Referrer is available
@@ -219,6 +219,24 @@ export const AppleSearchAdsNativeBridge = {
219
219
  }
220
220
  },
221
221
  };
222
+ export const AdvertiserInfoBridge = {
223
+ /**
224
+ * Get advertiser info (IDFA, IDFV, ATT status)
225
+ * IDFA is only available when ATT is authorized (iOS 14+)
226
+ * IDFV is always available on iOS
227
+ */
228
+ async getAdvertiserInfo() {
229
+ if (!DatalyrNative)
230
+ return null;
231
+ try {
232
+ return await DatalyrNative.getAdvertiserInfo();
233
+ }
234
+ catch (error) {
235
+ console.error('[Datalyr/AdvertiserInfo] Get advertiser info failed:', error);
236
+ return null;
237
+ }
238
+ },
239
+ };
222
240
  // MARK: - Play Install Referrer Bridge (Android only)
223
241
  export const PlayInstallReferrerNativeBridge = {
224
242
  /**
@@ -2,4 +2,5 @@
2
2
  * Native Module Exports
3
3
  */
4
4
  export { SKAdNetworkBridge } from './SKAdNetworkBridge';
5
- export { isNativeModuleAvailable, getSDKAvailability, MetaNativeBridge, TikTokNativeBridge, } from './DatalyrNativeBridge';
5
+ export { isNativeModuleAvailable, getSDKAvailability, MetaNativeBridge, TikTokNativeBridge, AdvertiserInfoBridge, } from './DatalyrNativeBridge';
6
+ export type { AdvertiserInfo } from './DatalyrNativeBridge';
@@ -2,4 +2,4 @@
2
2
  * Native Module Exports
3
3
  */
4
4
  export { SKAdNetworkBridge } from './SKAdNetworkBridge';
5
- export { isNativeModuleAvailable, getSDKAvailability, MetaNativeBridge, TikTokNativeBridge, } from './DatalyrNativeBridge';
5
+ export { isNativeModuleAvailable, getSDKAvailability, MetaNativeBridge, TikTokNativeBridge, AdvertiserInfoBridge, } from './DatalyrNativeBridge';
package/lib/types.d.ts CHANGED
@@ -83,9 +83,9 @@ export interface DatalyrConfig {
83
83
  maxEventQueueSize?: number;
84
84
  /** Respect browser Do Not Track setting. Default: true */
85
85
  respectDoNotTrack?: boolean;
86
- /** Enable automatic event tracking (sessions, app lifecycle). Default: false */
86
+ /** Enable automatic event tracking (sessions, app lifecycle). Default: true */
87
87
  enableAutoEvents?: boolean;
88
- /** Enable attribution tracking (deep links, install referrer). Default: false */
88
+ /** Enable attribution tracking (deep links, install referrer). Default: true */
89
89
  enableAttribution?: boolean;
90
90
  /** Enable web-to-app attribution matching via email. Default: true */
91
91
  enableWebToAppAttribution?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalyr/react-native",
3
- "version": "1.4.8",
3
+ "version": "1.5.0",
4
4
  "description": "Datalyr SDK for React Native & Expo - Server-side attribution tracking with bundled Meta and TikTok SDKs for iOS and Android",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -34,9 +34,9 @@ import { journeyManager } from './journey';
34
34
  import { createAutoEventsManager, AutoEventsManager } from './auto-events';
35
35
  import { ConversionValueEncoder, ConversionTemplates } from './ConversionValueEncoder';
36
36
  import { SKAdNetworkBridge } from './native/SKAdNetworkBridge';
37
- import { metaIntegration, tiktokIntegration, appleSearchAdsIntegration } from './integrations';
37
+ import { metaIntegration, tiktokIntegration, appleSearchAdsIntegration, playInstallReferrerIntegration } from './integrations';
38
38
  import { DeferredDeepLinkResult } from './types';
39
- import { AppleSearchAdsAttribution } from './native/DatalyrNativeBridge';
39
+ import { AppleSearchAdsAttribution, AdvertiserInfoBridge } from './native/DatalyrNativeBridge';
40
40
 
41
41
  export class DatalyrSDKExpo {
42
42
  private state: SDKState;
@@ -44,6 +44,7 @@ export class DatalyrSDKExpo {
44
44
  private eventQueue: EventQueue;
45
45
  private autoEventsManager: AutoEventsManager | null = null;
46
46
  private appStateSubscription: any = null;
47
+ private cachedAdvertiserInfo: any = null;
47
48
  private static conversionEncoder?: ConversionValueEncoder;
48
49
  private static debugEnabled = false;
49
50
 
@@ -59,9 +60,11 @@ export class DatalyrSDKExpo {
59
60
  maxRetries: 3,
60
61
  retryDelay: 1000,
61
62
  batchSize: 10,
62
- flushInterval: 10000,
63
+ flushInterval: 30000,
63
64
  maxQueueSize: 100,
64
65
  respectDoNotTrack: true,
66
+ enableAutoEvents: true,
67
+ enableAttribution: true,
65
68
  },
66
69
  visitorId: '',
67
70
  anonymousId: '',
@@ -198,10 +201,22 @@ export class DatalyrSDKExpo {
198
201
 
199
202
  // Initialize Apple Search Ads attribution (iOS only, auto-fetches on init)
200
203
  await appleSearchAdsIntegration.initialize(config.debug);
204
+
205
+ // Initialize Play Install Referrer (Android only)
206
+ await playInstallReferrerIntegration.initialize();
207
+
208
+ // Cache advertiser info (IDFA/GAID, ATT status) once at init to avoid per-event native bridge calls
209
+ try {
210
+ this.cachedAdvertiserInfo = await AdvertiserInfoBridge.getAdvertiserInfo();
211
+ } catch (error) {
212
+ errorLog('Failed to cache advertiser info:', error as Error);
213
+ }
214
+
201
215
  debugLog('Platform integrations initialized', {
202
216
  meta: metaIntegration.isAvailable(),
203
217
  tiktok: tiktokIntegration.isAvailable(),
204
218
  appleSearchAds: appleSearchAdsIntegration.isAvailable(),
219
+ playInstallReferrer: playInstallReferrerIntegration.isAvailable(),
205
220
  });
206
221
 
207
222
  this.state.initialized = true;
@@ -210,7 +225,7 @@ export class DatalyrSDKExpo {
210
225
  const installData = await attributionManager.trackInstall();
211
226
  await this.track('app_install', {
212
227
  platform: Platform.OS,
213
- sdk_version: '1.1.0',
228
+ sdk_version: '1.4.9',
214
229
  sdk_variant: 'expo',
215
230
  ...installData,
216
231
  });
@@ -694,14 +709,23 @@ export class DatalyrSDKExpo {
694
709
  return null;
695
710
  }
696
711
 
697
- getPlatformIntegrationStatus(): { meta: boolean; tiktok: boolean; appleSearchAds: boolean } {
712
+ getPlatformIntegrationStatus(): { meta: boolean; tiktok: boolean; appleSearchAds: boolean; playInstallReferrer: boolean } {
698
713
  return {
699
714
  meta: metaIntegration.isAvailable(),
700
715
  tiktok: tiktokIntegration.isAvailable(),
701
716
  appleSearchAds: appleSearchAdsIntegration.isAvailable(),
717
+ playInstallReferrer: playInstallReferrerIntegration.isAvailable(),
702
718
  };
703
719
  }
704
720
 
721
+ /**
722
+ * Get Play Install Referrer data (Android only)
723
+ */
724
+ getPlayInstallReferrer(): Record<string, any> | null {
725
+ const data = playInstallReferrerIntegration.getReferrerData();
726
+ return data ? playInstallReferrerIntegration.getAttributionData() : null;
727
+ }
728
+
705
729
  /**
706
730
  * Get Apple Search Ads attribution data
707
731
  * Returns attribution if user installed via Apple Search Ads, null otherwise
@@ -717,6 +741,13 @@ export class DatalyrSDKExpo {
717
741
  if (tiktokIntegration.isAvailable()) {
718
742
  tiktokIntegration.updateTrackingAuthorization(authorized);
719
743
  }
744
+
745
+ // Refresh cached advertiser info after ATT status change
746
+ try {
747
+ this.cachedAdvertiserInfo = await AdvertiserInfoBridge.getAdvertiserInfo();
748
+ } catch (error) {
749
+ errorLog('Failed to refresh advertiser info:', error as Error);
750
+ }
720
751
  }
721
752
 
722
753
  private async handleDeferredDeepLink(data: DeferredDeepLinkResult): Promise<void> {
@@ -759,7 +790,7 @@ export class DatalyrSDKExpo {
759
790
  const deviceInfo = await getDeviceInfo();
760
791
  const fingerprintData = await createFingerprintData();
761
792
  const attributionData = attributionManager.getAttributionData();
762
- const networkType = await getNetworkType();
793
+ const networkType = getNetworkType();
763
794
 
764
795
  // Get Apple Search Ads attribution if available
765
796
  const asaAttribution = appleSearchAdsIntegration.getAttributionData();
@@ -777,6 +808,9 @@ export class DatalyrSDKExpo {
777
808
  asa_country_or_region: asaAttribution.countryOrRegion,
778
809
  } : {};
779
810
 
811
+ // Use cached advertiser info (IDFA/GAID, ATT status) — cached at init, refreshed on ATT change
812
+ const advertiserInfo = this.cachedAdvertiserInfo;
813
+
780
814
  const payload: EventPayload = {
781
815
  workspaceId: this.state.config.workspaceId || 'mobile_sdk',
782
816
  visitorId: this.state.visitorId,
@@ -792,9 +826,25 @@ export class DatalyrSDKExpo {
792
826
  device_model: deviceInfo.model,
793
827
  app_version: deviceInfo.appVersion,
794
828
  app_build: deviceInfo.buildNumber,
829
+ app_name: deviceInfo.bundleId,
830
+ app_namespace: deviceInfo.bundleId,
831
+ screen_width: deviceInfo.screenWidth,
832
+ screen_height: deviceInfo.screenHeight,
833
+ locale: deviceInfo.locale,
834
+ timezone: deviceInfo.timezone,
835
+ carrier: deviceInfo.carrier,
795
836
  network_type: networkType,
796
837
  timestamp: Date.now(),
838
+ sdk_version: '1.4.9',
797
839
  sdk_variant: 'expo',
840
+ // Advertiser data (IDFA/GAID, ATT status) for Meta CAPI / TikTok Events API
841
+ ...(advertiserInfo ? {
842
+ idfa: advertiserInfo.idfa,
843
+ idfv: advertiserInfo.idfv,
844
+ gaid: advertiserInfo.gaid,
845
+ att_status: advertiserInfo.att_status,
846
+ advertiser_tracking_enabled: advertiserInfo.advertiser_tracking_enabled,
847
+ } : {}),
798
848
  ...attributionData,
799
849
  // Apple Search Ads attribution
800
850
  ...asaData,
@@ -1030,10 +1080,14 @@ export class DatalyrExpo {
1030
1080
  return datalyrExpo.getDeferredAttributionData();
1031
1081
  }
1032
1082
 
1033
- static getPlatformIntegrationStatus(): { meta: boolean; tiktok: boolean; appleSearchAds: boolean } {
1083
+ static getPlatformIntegrationStatus(): { meta: boolean; tiktok: boolean; appleSearchAds: boolean; playInstallReferrer: boolean } {
1034
1084
  return datalyrExpo.getPlatformIntegrationStatus();
1035
1085
  }
1036
1086
 
1087
+ static getPlayInstallReferrer(): Record<string, any> | null {
1088
+ return datalyrExpo.getPlayInstallReferrer();
1089
+ }
1090
+
1037
1091
  static getAppleSearchAdsAttribution(): AppleSearchAdsAttribution | null {
1038
1092
  return datalyrExpo.getAppleSearchAdsAttribution();
1039
1093
  }
@@ -32,7 +32,7 @@ import { createAutoEventsManager, AutoEventsManager, SessionData } from './auto-
32
32
  import { ConversionValueEncoder, ConversionTemplates } from './ConversionValueEncoder';
33
33
  import { SKAdNetworkBridge } from './native/SKAdNetworkBridge';
34
34
  import { metaIntegration, tiktokIntegration, appleSearchAdsIntegration, playInstallReferrerIntegration } from './integrations';
35
- import { AppleSearchAdsAttribution } from './native/DatalyrNativeBridge';
35
+ import { AppleSearchAdsAttribution, AdvertiserInfoBridge } from './native/DatalyrNativeBridge';
36
36
  import { networkStatusManager } from './network-status';
37
37
 
38
38
  export class DatalyrSDK {
@@ -42,6 +42,7 @@ export class DatalyrSDK {
42
42
  private autoEventsManager: AutoEventsManager | null = null;
43
43
  private appStateSubscription: any = null;
44
44
  private networkStatusUnsubscribe: (() => void) | null = null;
45
+ private cachedAdvertiserInfo: any = null;
45
46
  private static conversionEncoder?: ConversionValueEncoder;
46
47
  private static debugEnabled = false;
47
48
 
@@ -58,9 +59,11 @@ export class DatalyrSDK {
58
59
  maxRetries: 3,
59
60
  retryDelay: 1000,
60
61
  batchSize: 10,
61
- flushInterval: 10000,
62
+ flushInterval: 30000,
62
63
  maxQueueSize: 100,
63
64
  respectDoNotTrack: true,
65
+ enableAutoEvents: true,
66
+ enableAttribution: true,
64
67
  },
65
68
  visitorId: '',
66
69
  anonymousId: '', // Persistent anonymous identifier
@@ -221,6 +224,13 @@ export class DatalyrSDK {
221
224
  // Wait for all platform integrations to complete
222
225
  await Promise.all(platformInitPromises);
223
226
 
227
+ // Cache advertiser info (IDFA/GAID, ATT status) once at init to avoid per-event native bridge calls
228
+ try {
229
+ this.cachedAdvertiserInfo = await AdvertiserInfoBridge.getAdvertiserInfo();
230
+ } catch (error) {
231
+ errorLog('Failed to cache advertiser info:', error as Error);
232
+ }
233
+
224
234
  debugLog('Platform integrations initialized', {
225
235
  meta: metaIntegration.isAvailable(),
226
236
  tiktok: tiktokIntegration.isAvailable(),
@@ -233,10 +243,16 @@ export class DatalyrSDK {
233
243
 
234
244
  // Check for app install (after SDK is marked as initialized)
235
245
  if (attributionManager.isInstall()) {
246
+ // iOS: Attempt deferred web-to-app attribution via IP matching before tracking install
247
+ // Android: Play Store referrer is handled by playInstallReferrerIntegration
248
+ if (Platform.OS === 'ios') {
249
+ await this.fetchDeferredWebAttribution();
250
+ }
251
+
236
252
  const installData = await attributionManager.trackInstall();
237
253
  await this.track('app_install', {
238
254
  platform: Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : 'android',
239
- sdk_version: '1.0.2',
255
+ sdk_version: '1.4.9',
240
256
  ...installData,
241
257
  });
242
258
  }
@@ -443,6 +459,88 @@ export class DatalyrSDK {
443
459
  }
444
460
  }
445
461
 
462
+ /**
463
+ * Fetch deferred web attribution on first app install.
464
+ * Uses IP-based matching (iOS) or Play Store referrer (Android) to recover
465
+ * attribution data (fbclid, utm_*, etc.) from a prelander web visit.
466
+ * Called automatically during initialize() when a fresh install is detected.
467
+ */
468
+ private async fetchDeferredWebAttribution(): Promise<void> {
469
+ if (!this.state.config?.apiKey) {
470
+ debugLog('API key not available for deferred attribution fetch');
471
+ return;
472
+ }
473
+
474
+ try {
475
+ debugLog('Fetching deferred web attribution via IP matching...');
476
+
477
+ const baseUrl = this.state.config.endpoint || 'https://api.datalyr.com';
478
+ const controller = new AbortController();
479
+ const timeout = setTimeout(() => controller.abort(), 10000);
480
+
481
+ const response = await fetch(`${baseUrl}/attribution/deferred-lookup`, {
482
+ method: 'POST',
483
+ headers: {
484
+ 'Content-Type': 'application/json',
485
+ 'X-Datalyr-API-Key': this.state.config.apiKey,
486
+ },
487
+ body: JSON.stringify({ platform: Platform.OS }),
488
+ signal: controller.signal,
489
+ });
490
+
491
+ clearTimeout(timeout);
492
+
493
+ if (!response.ok) {
494
+ debugLog('Deferred attribution lookup failed:', response.status);
495
+ return;
496
+ }
497
+
498
+ const result = await response.json() as { found: boolean; attribution?: any };
499
+
500
+ if (!result.found || !result.attribution) {
501
+ debugLog('No deferred web attribution found for this IP');
502
+ return;
503
+ }
504
+
505
+ const webAttribution = result.attribution;
506
+ debugLog('Deferred web attribution found:', {
507
+ visitor_id: webAttribution.visitor_id,
508
+ has_fbclid: !!webAttribution.fbclid,
509
+ has_gclid: !!webAttribution.gclid,
510
+ utm_source: webAttribution.utm_source,
511
+ });
512
+
513
+ // Merge web attribution into current session
514
+ attributionManager.mergeWebAttribution(webAttribution);
515
+
516
+ // Track match event for analytics
517
+ await this.track('$web_attribution_matched', {
518
+ web_visitor_id: webAttribution.visitor_id,
519
+ web_user_id: webAttribution.user_id,
520
+ fbclid: webAttribution.fbclid,
521
+ gclid: webAttribution.gclid,
522
+ ttclid: webAttribution.ttclid,
523
+ gbraid: webAttribution.gbraid,
524
+ wbraid: webAttribution.wbraid,
525
+ fbp: webAttribution.fbp,
526
+ fbc: webAttribution.fbc,
527
+ utm_source: webAttribution.utm_source,
528
+ utm_medium: webAttribution.utm_medium,
529
+ utm_campaign: webAttribution.utm_campaign,
530
+ utm_content: webAttribution.utm_content,
531
+ utm_term: webAttribution.utm_term,
532
+ web_timestamp: webAttribution.timestamp,
533
+ match_method: 'ip',
534
+ });
535
+
536
+ debugLog('Successfully merged deferred web attribution');
537
+
538
+ } catch (error) {
539
+ errorLog('Error fetching deferred web attribution:', error as Error);
540
+ // Non-blocking - email-based fallback will catch this on identify()
541
+ }
542
+ }
543
+
446
544
  /**
447
545
  * Alias a user (connect anonymous user to known user)
448
546
  */
@@ -939,6 +1037,13 @@ export class DatalyrSDK {
939
1037
  metaIntegration.updateTrackingAuthorization(enabled);
940
1038
  tiktokIntegration.updateTrackingAuthorization(enabled);
941
1039
 
1040
+ // Refresh cached advertiser info after ATT status change
1041
+ try {
1042
+ this.cachedAdvertiserInfo = await AdvertiserInfoBridge.getAdvertiserInfo();
1043
+ } catch (error) {
1044
+ errorLog('Failed to refresh advertiser info:', error as Error);
1045
+ }
1046
+
942
1047
  // Track ATT status event
943
1048
  await this.track('$att_status', {
944
1049
  authorized: enabled,
@@ -1022,6 +1127,9 @@ export class DatalyrSDK {
1022
1127
  asa_country_or_region: asaAttribution.countryOrRegion,
1023
1128
  } : {};
1024
1129
 
1130
+ // Use cached advertiser info (IDFA/GAID, ATT status) — cached at init, refreshed on ATT change
1131
+ const advertiserInfo = this.cachedAdvertiserInfo;
1132
+
1025
1133
  const payload: EventPayload = {
1026
1134
  workspaceId: this.state.config.workspaceId || 'mobile_sdk',
1027
1135
  visitorId: this.state.visitorId,
@@ -1039,8 +1147,24 @@ export class DatalyrSDK {
1039
1147
  device_model: deviceInfo.model,
1040
1148
  app_version: deviceInfo.appVersion,
1041
1149
  app_build: deviceInfo.buildNumber,
1150
+ app_name: deviceInfo.bundleId, // Best available app name
1151
+ app_namespace: deviceInfo.bundleId,
1152
+ screen_width: deviceInfo.screenWidth,
1153
+ screen_height: deviceInfo.screenHeight,
1154
+ locale: deviceInfo.locale,
1155
+ timezone: deviceInfo.timezone,
1156
+ carrier: deviceInfo.carrier,
1042
1157
  network_type: getNetworkType(),
1043
1158
  timestamp: Date.now(),
1159
+ sdk_version: '1.4.9',
1160
+ // Advertiser data (IDFA/GAID, ATT status) for Meta CAPI / TikTok Events API
1161
+ ...(advertiserInfo ? {
1162
+ idfa: advertiserInfo.idfa,
1163
+ idfv: advertiserInfo.idfv,
1164
+ gaid: advertiserInfo.gaid,
1165
+ att_status: advertiserInfo.att_status,
1166
+ advertiser_tracking_enabled: advertiserInfo.advertiser_tracking_enabled,
1167
+ } : {}),
1044
1168
  // Attribution data
1045
1169
  ...attributionData,
1046
1170
  // Apple Search Ads attribution
@@ -262,7 +262,7 @@ export const createEventQueue = (httpClient: HttpClient, config?: Partial<QueueC
262
262
  const defaultConfig: QueueConfig = {
263
263
  maxQueueSize: 100,
264
264
  batchSize: 10,
265
- flushInterval: 10000, // 10 seconds
265
+ flushInterval: 30000, // 30 seconds — matches SDK constructor defaults and docs
266
266
  maxRetryCount: 3,
267
267
  };
268
268
 
@@ -72,7 +72,7 @@ export class HttpClient {
72
72
 
73
73
  const headers: Record<string, string> = {
74
74
  'Content-Type': 'application/json',
75
- 'User-Agent': `@datalyr/react-native/1.0.2`,
75
+ 'User-Agent': `@datalyr/react-native/1.4.8`,
76
76
  };
77
77
 
78
78
  // Server-side tracking uses X-API-Key header
@@ -188,7 +188,7 @@ export class HttpClient {
188
188
  return {
189
189
  event: payload.eventName,
190
190
  userId: payload.userId || payload.visitorId,
191
- anonymousId: payload.visitorId,
191
+ anonymousId: payload.anonymousId || payload.visitorId,
192
192
  properties: {
193
193
  ...payload.eventData,
194
194
  sessionId: payload.sessionId,
@@ -197,7 +197,7 @@ export class HttpClient {
197
197
  },
198
198
  context: {
199
199
  library: '@datalyr/react-native',
200
- version: '1.0.5',
200
+ version: '1.4.8',
201
201
  source: 'mobile_app', // Explicitly set source for mobile
202
202
  userProperties: payload.userProperties,
203
203
  },
@@ -76,9 +76,11 @@ export class TikTokIntegration {
76
76
  if (success) {
77
77
  this.initialized = true;
78
78
  this.log(`TikTok SDK initialized with App ID: ${config.tiktokAppId}`);
79
+ } else {
80
+ console.warn('[Datalyr/TikTok] TikTok SDK not initialized (accessToken may be missing). Events will still be sent server-side via Datalyr postbacks.');
79
81
  }
80
82
  } catch (error) {
81
- this.logError('Failed to initialize TikTok SDK:', error);
83
+ console.warn('[Datalyr/TikTok] TikTok SDK init failed. Events will still be sent server-side via Datalyr postbacks.', error);
82
84
  }
83
85
  }
84
86
 
@@ -90,6 +90,15 @@ interface DatalyrNativeModule {
90
90
  logoutTikTok(): Promise<boolean>;
91
91
  updateTikTokTrackingAuthorization(enabled: boolean): Promise<boolean>;
92
92
 
93
+ // Advertiser Info (IDFA, IDFV, GAID, ATT Status)
94
+ getAdvertiserInfo(): Promise<{
95
+ idfa?: string;
96
+ idfv?: string;
97
+ gaid?: string;
98
+ att_status: number;
99
+ advertiser_tracking_enabled: boolean;
100
+ } | null>;
101
+
93
102
  // Apple Search Ads Methods (iOS only)
94
103
  getAppleSearchAdsAttribution(): Promise<AppleSearchAdsAttribution | null>;
95
104
 
@@ -374,6 +383,34 @@ export const AppleSearchAdsNativeBridge = {
374
383
  },
375
384
  };
376
385
 
386
+ // MARK: - Advertiser Info Bridge
387
+
388
+ export interface AdvertiserInfo {
389
+ idfa?: string;
390
+ idfv?: string;
391
+ gaid?: string;
392
+ att_status: number;
393
+ advertiser_tracking_enabled: boolean;
394
+ }
395
+
396
+ export const AdvertiserInfoBridge = {
397
+ /**
398
+ * Get advertiser info (IDFA, IDFV, ATT status)
399
+ * IDFA is only available when ATT is authorized (iOS 14+)
400
+ * IDFV is always available on iOS
401
+ */
402
+ async getAdvertiserInfo(): Promise<AdvertiserInfo | null> {
403
+ if (!DatalyrNative) return null;
404
+
405
+ try {
406
+ return await DatalyrNative.getAdvertiserInfo();
407
+ } catch (error) {
408
+ console.error('[Datalyr/AdvertiserInfo] Get advertiser info failed:', error);
409
+ return null;
410
+ }
411
+ },
412
+ };
413
+
377
414
  // MARK: - Play Install Referrer Bridge (Android only)
378
415
 
379
416
  export const PlayInstallReferrerNativeBridge = {
@@ -8,4 +8,6 @@ export {
8
8
  getSDKAvailability,
9
9
  MetaNativeBridge,
10
10
  TikTokNativeBridge,
11
+ AdvertiserInfoBridge,
11
12
  } from './DatalyrNativeBridge';
13
+ export type { AdvertiserInfo } from './DatalyrNativeBridge';
package/src/types.ts CHANGED
@@ -105,10 +105,10 @@ export interface DatalyrConfig {
105
105
  /** Respect browser Do Not Track setting. Default: true */
106
106
  respectDoNotTrack?: boolean;
107
107
 
108
- /** Enable automatic event tracking (sessions, app lifecycle). Default: false */
108
+ /** Enable automatic event tracking (sessions, app lifecycle). Default: true */
109
109
  enableAutoEvents?: boolean;
110
110
 
111
- /** Enable attribution tracking (deep links, install referrer). Default: false */
111
+ /** Enable attribution tracking (deep links, install referrer). Default: true */
112
112
  enableAttribution?: boolean;
113
113
 
114
114
  /** Enable web-to-app attribution matching via email. Default: true */
package/src/utils-expo.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import AsyncStorage from '@react-native-async-storage/async-storage';
2
- import { Platform } from 'react-native';
2
+ import { Platform, Dimensions } from 'react-native';
3
3
  import * as Application from 'expo-application';
4
4
  import * as Device from 'expo-device';
5
5
  import * as Network from 'expo-network';
@@ -84,10 +84,14 @@ export interface DeviceInfo {
84
84
  isEmulator: boolean;
85
85
  }
86
86
 
87
- export const getDeviceInfo = async (): Promise<DeviceInfo> => {
87
+ // Cached device info to avoid repeated async calls (matches utils.ts pattern)
88
+ let cachedDeviceInfo: DeviceInfo | null = null;
89
+ let deviceInfoPromise: Promise<DeviceInfo> | null = null;
90
+
91
+ const fetchDeviceInfoInternal = async (): Promise<DeviceInfo> => {
88
92
  try {
89
93
  const deviceId = await getOrCreateDeviceId();
90
-
94
+
91
95
  return {
92
96
  deviceId,
93
97
  model: Device.modelName || Device.deviceName || 'Unknown',
@@ -96,10 +100,10 @@ export const getDeviceInfo = async (): Promise<DeviceInfo> => {
96
100
  appVersion: Application.nativeApplicationVersion || '1.0.0',
97
101
  buildNumber: Application.nativeBuildVersion || '1',
98
102
  bundleId: Application.applicationId || 'unknown.bundle.id',
99
- screenWidth: 0, // Would need Dimensions from react-native
100
- screenHeight: 0, // Would need Dimensions from react-native
103
+ screenWidth: Dimensions.get('window').width,
104
+ screenHeight: Dimensions.get('window').height,
101
105
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
102
- locale: 'en-US', // Would need expo-localization for full locale
106
+ locale: Intl.DateTimeFormat().resolvedOptions().locale || 'en-US',
103
107
  carrier: undefined, // Not available in Expo managed workflow
104
108
  isEmulator: !Device.isDevice,
105
109
  };
@@ -113,8 +117,8 @@ export const getDeviceInfo = async (): Promise<DeviceInfo> => {
113
117
  appVersion: '1.0.0',
114
118
  buildNumber: '1',
115
119
  bundleId: 'unknown.bundle.id',
116
- screenWidth: 0,
117
- screenHeight: 0,
120
+ screenWidth: Dimensions.get('window').width,
121
+ screenHeight: Dimensions.get('window').height,
118
122
  timezone: 'UTC',
119
123
  locale: 'en-US',
120
124
  isEmulator: true,
@@ -122,6 +126,19 @@ export const getDeviceInfo = async (): Promise<DeviceInfo> => {
122
126
  }
123
127
  };
124
128
 
129
+ export const getDeviceInfo = async (): Promise<DeviceInfo> => {
130
+ if (cachedDeviceInfo) return cachedDeviceInfo;
131
+ if (deviceInfoPromise) return deviceInfoPromise;
132
+
133
+ deviceInfoPromise = fetchDeviceInfoInternal();
134
+ try {
135
+ cachedDeviceInfo = await deviceInfoPromise;
136
+ return cachedDeviceInfo;
137
+ } finally {
138
+ deviceInfoPromise = null;
139
+ }
140
+ };
141
+
125
142
  // Device ID management
126
143
  const getOrCreateDeviceId = async (): Promise<string> => {
127
144
  try {
@@ -246,30 +263,49 @@ export const createFingerprintData = async () => {
246
263
  // IDFA/GAID collection has been removed for privacy compliance
247
264
  // Modern attribution tracking relies on privacy-safe methods:
248
265
 
249
- // Network type detection using Expo Network
250
- export const getNetworkType = async (): Promise<string> => {
266
+ // Cached network type to avoid per-event native bridge calls
267
+ let cachedNetworkType = 'unknown';
268
+ let networkTypeLastFetched = 0;
269
+ const NETWORK_TYPE_CACHE_MS = 30000; // Refresh every 30s
270
+
271
+ // Network type detection using Expo Network — cached to avoid per-event async calls
272
+ export const getNetworkType = (): string => {
273
+ // Trigger background refresh if stale, but always return cached value synchronously
274
+ const now = Date.now();
275
+ if (now - networkTypeLastFetched > NETWORK_TYPE_CACHE_MS) {
276
+ networkTypeLastFetched = now;
277
+ refreshNetworkType();
278
+ }
279
+ return cachedNetworkType;
280
+ };
281
+
282
+ const refreshNetworkType = async (): Promise<void> => {
251
283
  try {
252
284
  const networkState = await Network.getNetworkStateAsync();
253
-
285
+
254
286
  if (!networkState.isConnected) {
255
- return 'none';
287
+ cachedNetworkType = 'none';
288
+ return;
256
289
  }
257
-
290
+
258
291
  switch (networkState.type) {
259
292
  case Network.NetworkStateType.WIFI:
260
- return 'wifi';
293
+ cachedNetworkType = 'wifi';
294
+ break;
261
295
  case Network.NetworkStateType.CELLULAR:
262
- return 'cellular';
296
+ cachedNetworkType = 'cellular';
297
+ break;
263
298
  case Network.NetworkStateType.ETHERNET:
264
- return 'ethernet';
299
+ cachedNetworkType = 'ethernet';
300
+ break;
265
301
  case Network.NetworkStateType.BLUETOOTH:
266
- return 'bluetooth';
302
+ cachedNetworkType = 'bluetooth';
303
+ break;
267
304
  default:
268
- return 'unknown';
305
+ cachedNetworkType = 'unknown';
269
306
  }
270
307
  } catch (error) {
271
308
  debugLog('Error getting network type:', error);
272
- return 'unknown';
273
309
  }
274
310
  };
275
311