import AsyncStorage from '@react-native-async-storage/async-storage'; import { Platform, Dimensions } from 'react-native'; import * as Application from 'expo-application'; import * as Device from 'expo-device'; import * as Network from 'expo-network'; import 'react-native-get-random-values'; import { v4 as uuidv4 } from 'uuid'; // Storage keys export const STORAGE_KEYS = { VISITOR_ID: '@datalyr/visitor_id', ANONYMOUS_ID: '@datalyr/anonymous_id', // Persistent anonymous identifier SESSION_ID: '@datalyr/session_id', SESSION_START: '@datalyr/session_start', USER_ID: '@datalyr/user_id', USER_PROPERTIES: '@datalyr/user_properties', EVENT_QUEUE: '@datalyr/event_queue', ATTRIBUTION_DATA: '@datalyr/attribution_data', INSTALL_TIME: '@datalyr/install_time', LAST_APP_VERSION: '@datalyr/last_app_version', LAST_SESSION_TIME: '@datalyr/last_session_time', DEVICE_ID: '@datalyr/device_id', } as const; // Debug logging export const debugLog = (message: string, ...args: any[]) => { if (__DEV__) { console.log(`[Datalyr] ${message}`, ...args); } }; export const errorLog = (message: string, error?: Error) => { // Gate on __DEV__ so the SDK never spams the host app's PRODUCTION console // (matches the RN build's utils.ts errorLog). RN-22. if (__DEV__) { console.error(`[Datalyr Error] ${message}`, error); } }; // UUID generation export const generateUUID = (): string => { return uuidv4(); }; /** * Derive an ISO-3166-1 alpha-2 country code from a BCP-47 locale tag. * Mirrors utils.ts — see that file for the rationale. Kept duplicated rather * than re-exported because the Expo build deliberately doesn't pull from * utils.ts (which transitively imports react-native-device-info). */ export const deriveCountryFromLocale = (locale: string | undefined | null): string | null => { if (!locale) return null; // BCP-47 may include a script subtag before the region (e.g. zh-Hant-TW), so scan // ALL post-language segments for the first ISO-3166-1 alpha-2 region, not just [1]. for (const seg of locale.split(/[-_]/).slice(1)) { const upper = seg.toUpperCase(); if (/^[A-Z]{2}$/.test(upper)) return upper; } return null; }; // Storage utilities export const Storage = { async getItem(key: string): Promise { try { const value = await AsyncStorage.getItem(key); return value ? JSON.parse(value) : null; } catch (error) { errorLog(`Failed to get storage item ${key}:`, error as Error); return null; } }, async setItem(key: string, value: T): Promise { try { await AsyncStorage.setItem(key, JSON.stringify(value)); } catch (error) { errorLog(`Failed to set storage item ${key}:`, error as Error); } }, async removeItem(key: string): Promise { try { await AsyncStorage.removeItem(key); } catch (error) { errorLog(`Failed to remove storage item ${key}:`, error as Error); } }, }; // Device info using Expo APIs export interface DeviceInfo { deviceId: string; model: string; manufacturer: string; osVersion: string; appVersion: string; buildNumber: string; bundleId: string; screenWidth: number; screenHeight: number; timezone: string; // Optional: the error fallback omits it rather than hardcoding 'en-US'. locale?: string; carrier?: string; isEmulator: boolean; } // Cached device info to avoid repeated async calls (matches utils.ts pattern) let cachedDeviceInfo: DeviceInfo | null = null; let deviceInfoPromise: Promise | null = null; const fetchDeviceInfoInternal = async (): Promise => { try { const deviceId = await getOrCreateDeviceId(); return { deviceId, model: Device.modelName || Device.deviceName || 'Unknown', manufacturer: Device.manufacturer || (Platform.OS === 'ios' ? 'Apple' : 'Unknown'), osVersion: Device.osVersion || 'Unknown', appVersion: Application.nativeApplicationVersion || '1.0.0', buildNumber: Application.nativeBuildVersion || '1', bundleId: Application.applicationId || 'unknown.bundle.id', screenWidth: Dimensions.get('window').width, screenHeight: Dimensions.get('window').height, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, locale: Intl.DateTimeFormat().resolvedOptions().locale || 'en-US', carrier: undefined, // Not available in Expo managed workflow isEmulator: !Device.isDevice, }; } catch (error) { errorLog('Error getting device info:', error as Error); let fallbackLocale: string | undefined; let fallbackTimezone = 'UTC'; try { const opts = Intl.DateTimeFormat().resolvedOptions(); // Real runtime locale (was hardcoded 'en-US', which fabricated country='US' on // every event and overrode accurate Cloudflare geo for Meta CAPI matching). fallbackLocale = opts.locale || undefined; fallbackTimezone = opts.timeZone || 'UTC'; } catch { // Leave locale undefined → country omitted, not fabricated. } return { deviceId: await getOrCreateDeviceId(), model: 'Unknown', manufacturer: Platform.OS === 'ios' ? 'Apple' : 'Unknown', osVersion: 'Unknown', appVersion: '1.0.0', buildNumber: '1', bundleId: 'unknown.bundle.id', screenWidth: Dimensions.get('window').width, screenHeight: Dimensions.get('window').height, timezone: fallbackTimezone, locale: fallbackLocale, isEmulator: true, }; } }; export const getDeviceInfo = async (): Promise => { if (cachedDeviceInfo) return cachedDeviceInfo; if (deviceInfoPromise) return deviceInfoPromise; deviceInfoPromise = fetchDeviceInfoInternal(); try { cachedDeviceInfo = await deviceInfoPromise; return cachedDeviceInfo; } finally { deviceInfoPromise = null; } }; // Device ID management const getOrCreateDeviceId = async (): Promise => { try { let deviceId = await Storage.getItem(STORAGE_KEYS.DEVICE_ID); if (!deviceId) { deviceId = generateUUID(); await Storage.setItem(STORAGE_KEYS.DEVICE_ID, deviceId); } return deviceId; } catch (error) { errorLog('Error managing device ID:', error as Error); return generateUUID(); } }; // Visitor ID management export const getOrCreateVisitorId = async (): Promise => { try { let visitorId = await Storage.getItem(STORAGE_KEYS.VISITOR_ID); if (!visitorId) { visitorId = generateUUID(); await Storage.setItem(STORAGE_KEYS.VISITOR_ID, visitorId); debugLog('Created new visitor ID:', visitorId); } return visitorId; } catch (error) { errorLog('Error managing visitor ID:', error as Error); return generateUUID(); } }; // Anonymous ID management - persistent across app reinstalls export const getOrCreateAnonymousId = async (): Promise => { try { let anonymousId = await Storage.getItem(STORAGE_KEYS.ANONYMOUS_ID); if (!anonymousId) { // Generate anonymous_id with anon_ prefix to match web SDK anonymousId = `anon_${generateUUID()}`; await Storage.setItem(STORAGE_KEYS.ANONYMOUS_ID, anonymousId); debugLog('Created new anonymous ID:', anonymousId); } return anonymousId; } catch (error) { errorLog('Error managing anonymous ID:', error as Error); return `anon_${generateUUID()}`; } }; // Session management export const getOrCreateSessionId = async (): Promise => { try { const sessionTimeout = 30 * 60 * 1000; // 30 minutes const now = Date.now(); // LAST_SESSION_TIME tracks the last ACTIVITY (refreshed on every reuse below), so the // 30-min window is an idle/sliding window — matching the RN build (utils.ts). The old // code compared against SESSION_START only, hard-expiring a session 30min after it // STARTED regardless of continuous use (and diverging from RN). SESSION_START is still // written for back-compat, but the timeout is anchored to last activity. const [sessionId, lastActivity] = await Promise.all([ Storage.getItem(STORAGE_KEYS.SESSION_ID), Storage.getItem(STORAGE_KEYS.LAST_SESSION_TIME), ]); // Check if session is still valid (idle window from last activity) if (sessionId && lastActivity && (now - lastActivity) < sessionTimeout) { // Slide the window: refresh last-activity time on reuse. await Storage.setItem(STORAGE_KEYS.LAST_SESSION_TIME, now); return sessionId; } // Create new session const newSessionId = generateUUID(); await Promise.all([ Storage.setItem(STORAGE_KEYS.SESSION_ID, newSessionId), Storage.setItem(STORAGE_KEYS.SESSION_START, now), Storage.setItem(STORAGE_KEYS.LAST_SESSION_TIME, now), ]); debugLog('Created new session:', newSessionId); return newSessionId; } catch (error) { errorLog('Error managing session ID:', error as Error); return generateUUID(); } }; /** * Rotate the persistent anonymous ID (logout). See utils.ts for the full rationale — * prevents two users sharing one anon id in the identity graph. */ export const rotateAnonymousId = async (): Promise => { const anonymousId = `anon_${generateUUID()}`; try { await Storage.setItem(STORAGE_KEYS.ANONYMOUS_ID, anonymousId); } catch (error) { errorLog('Error rotating anonymous ID:', error as Error); } return anonymousId; }; /** * Force a brand-new session — clear stored session id + timestamps so the next * getOrCreateSessionId() creates (not resumes) one. Used by reset(). */ export const clearSession = async (): Promise => { try { await Promise.all([ Storage.removeItem(STORAGE_KEYS.SESSION_ID), Storage.removeItem(STORAGE_KEYS.SESSION_START), Storage.removeItem(STORAGE_KEYS.LAST_SESSION_TIME), ]); } catch (error) { errorLog('Error clearing session:', error as Error); } }; // Device context creation using Expo APIs export const createDeviceContext = async () => { try { const deviceInfo = await getDeviceInfo(); return { deviceId: deviceInfo.deviceId, deviceInfo: { model: deviceInfo.model, manufacturer: deviceInfo.manufacturer, osVersion: deviceInfo.osVersion, screenSize: `${deviceInfo.screenWidth}x${deviceInfo.screenHeight}`, timezone: deviceInfo.timezone, locale: deviceInfo.locale, carrier: deviceInfo.carrier, isEmulator: deviceInfo.isEmulator, }, }; } catch (error) { errorLog('Error creating device context:', error as Error); const deviceInfo = await getDeviceInfo(); return { deviceId: deviceInfo.deviceId, deviceInfo: { model: deviceInfo.model, manufacturer: deviceInfo.manufacturer, osVersion: deviceInfo.osVersion, screenSize: '0x0', timezone: deviceInfo.timezone, locale: deviceInfo.locale, isEmulator: deviceInfo.isEmulator, }, }; } }; // IDFA/GAID collection has been removed for privacy compliance // Modern attribution tracking relies on privacy-safe methods: // Cached network type to avoid per-event native bridge calls let cachedNetworkType = 'unknown'; let networkTypeLastFetched = 0; const NETWORK_TYPE_CACHE_MS = 30000; // Refresh every 30s // Network type detection using Expo Network — cached to avoid per-event async calls export const getNetworkType = (): string => { // Trigger background refresh if stale, but always return cached value synchronously const now = Date.now(); if (now - networkTypeLastFetched > NETWORK_TYPE_CACHE_MS) { networkTypeLastFetched = now; refreshNetworkType(); } return cachedNetworkType; }; const refreshNetworkType = async (): Promise => { try { const networkState = await Network.getNetworkStateAsync(); if (!networkState.isConnected) { cachedNetworkType = 'none'; return; } switch (networkState.type) { case Network.NetworkStateType.WIFI: cachedNetworkType = 'wifi'; break; case Network.NetworkStateType.CELLULAR: cachedNetworkType = 'cellular'; break; case Network.NetworkStateType.ETHERNET: cachedNetworkType = 'ethernet'; break; case Network.NetworkStateType.BLUETOOTH: cachedNetworkType = 'bluetooth'; break; default: cachedNetworkType = 'unknown'; } } catch (error) { debugLog('Error getting network type:', error); } }; // Event validation export const validateEventName = (eventName: string): boolean => { if (!eventName || typeof eventName !== 'string') { return false; } if (eventName.length > 100) { return false; } // Allow letters, numbers, underscores, hyphens, and $ prefix (for system events like $identify) const validPattern = /^\$?[a-zA-Z0-9_-]+$/; return validPattern.test(eventName); }; export const validateEventData = (eventData?: Record): boolean => { if (!eventData) { return true; } if (typeof eventData !== 'object' || Array.isArray(eventData)) { return false; } try { // Check if data can be serialized JSON.stringify(eventData); return true; } catch { return false; } }; // Install detection export const isFirstLaunch = async (): Promise => { try { const installTime = await Storage.getItem(STORAGE_KEYS.INSTALL_TIME); if (!installTime) { // First launch - record install time await Storage.setItem(STORAGE_KEYS.INSTALL_TIME, new Date().toISOString()); return true; } return false; } catch (error) { errorLog('Error checking first launch:', error as Error); return false; } }; // App version tracking export const checkAppVersion = async (): Promise<{ isUpdate: boolean; previousVersion?: string; currentVersion: string }> => { try { const currentVersion = Application.nativeApplicationVersion || '1.0.0'; const currentBuild = Application.nativeBuildVersion || '1'; const versionString = `${currentVersion}-${currentBuild}`; const lastVersion = await Storage.getItem(STORAGE_KEYS.LAST_APP_VERSION); if (lastVersion && lastVersion !== versionString) { await Storage.setItem(STORAGE_KEYS.LAST_APP_VERSION, versionString); return { isUpdate: true, previousVersion: lastVersion, currentVersion: versionString, }; } if (!lastVersion) { await Storage.setItem(STORAGE_KEYS.LAST_APP_VERSION, versionString); } return { isUpdate: false, currentVersion: versionString, }; } catch (error) { errorLog('Error checking app version:', error as Error); return { isUpdate: false, currentVersion: '1.0.0-1', }; } };