diff --git a/src/authorization/authorization.ts b/src/authorization/authorization.ts index 24a6bdb7..78dde8d0 100644 --- a/src/authorization/authorization.ts +++ b/src/authorization/authorization.ts @@ -14,6 +14,7 @@ import { SHARED_PREF_USER_ID, RETRY_USER_ATTEMPTS } from '../constants'; +import { safeGetItem, safeSetItem, safeRemoveItem } from '../utils/safeStorage'; import { UnknownUserMerge } from '../unknownUserTracking/unknownUserMerge'; import { UnknownUserEventManager, @@ -93,7 +94,7 @@ const doesRequestUrlContain = (routeConfig: RouteConfig) => const addUserIdToRequest = (userId: string) => { setTypeOfAuth('userID'); authIdentifier = userId; - localStorage.setItem(SHARED_PREF_USER_ID, userId); + safeSetItem(SHARED_PREF_USER_ID, userId); if (typeof userInterceptor === 'number') { baseAxiosRequest.interceptors.request.eject(userInterceptor); @@ -217,18 +218,18 @@ export const setUnknownUserId = async (userId: string) => { } addUserIdToRequest(userId); - localStorage.setItem(SHARED_PREF_UNKNOWN_USER_ID, userId); + safeSetItem(SHARED_PREF_UNKNOWN_USER_ID, userId); }; registerUnknownUserIdSetter(setUnknownUserId); const clearUnknownUser = () => { - localStorage.removeItem(SHARED_PREF_UNKNOWN_USER_ID); + safeRemoveItem(SHARED_PREF_UNKNOWN_USER_ID); }; const getUnknownUserId = () => { if (config.getConfig('enableUnknownActivation')) { - const unknownUser = localStorage.getItem(SHARED_PREF_UNKNOWN_USER_ID); + const unknownUser = safeGetItem(SHARED_PREF_UNKNOWN_USER_ID); return unknownUser === undefined ? null : unknownUser; } return null; @@ -242,7 +243,7 @@ const initializeUserId = (userId: string) => { const addEmailToRequest = (email: string) => { setTypeOfAuth('email'); authIdentifier = email; - localStorage.setItem(SHARED_PREF_EMAIL, email); + safeSetItem(SHARED_PREF_EMAIL, email); if (typeof userInterceptor === 'number') { baseAxiosRequest.interceptors.request.eject(userInterceptor); @@ -621,9 +622,9 @@ export function initialize( unknownUserManager.removeUnknownSessionCriteriaData(); setTypeOfAuth(null); authIdentifier = null; - localStorage.removeItem(SHARED_PREF_EMAIL); - localStorage.removeItem(SHARED_PREF_USER_ID); - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeRemoveItem(SHARED_PREF_EMAIL); + safeRemoveItem(SHARED_PREF_USER_ID); + safeRemoveItem(SHARED_PREF_CONSENT_TIMESTAMP); /* clear fetched in-app messages */ clearMessages(); @@ -652,17 +653,12 @@ export function initialize( /* if consent is true, we want to clear unknown user data and start tracking */ if (consent) { unknownUserManager.removeUnknownSessionCriteriaData(); - localStorage.removeItem(SHARED_PREFS_CRITERIA); + safeRemoveItem(SHARED_PREFS_CRITERIA); // Store consent timestamp when user grants consent - const existingConsent = localStorage.getItem( - SHARED_PREF_CONSENT_TIMESTAMP - ); + const existingConsent = safeGetItem(SHARED_PREF_CONSENT_TIMESTAMP); if (!existingConsent) { - localStorage.setItem( - SHARED_PREF_CONSENT_TIMESTAMP, - Date.now().toString() - ); + safeSetItem(SHARED_PREF_CONSENT_TIMESTAMP, Date.now().toString()); } enableUnknownTracking(); } else { @@ -671,9 +667,9 @@ export function initialize( if (unknownUsageTracked) { unknownUserManager.removeUnknownSessionCriteriaData(); - localStorage.removeItem(SHARED_PREFS_CRITERIA); - localStorage.removeItem(SHARED_PREF_UNKNOWN_USER_ID); - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeRemoveItem(SHARED_PREFS_CRITERIA); + safeRemoveItem(SHARED_PREF_UNKNOWN_USER_ID); + safeRemoveItem(SHARED_PREF_CONSENT_TIMESTAMP); setTypeOfAuth(null); authIdentifier = null; @@ -1066,9 +1062,9 @@ export function initialize( unknownUserManager.removeUnknownSessionCriteriaData(); setTypeOfAuth(null); authIdentifier = null; - localStorage.removeItem(SHARED_PREF_EMAIL); - localStorage.removeItem(SHARED_PREF_USER_ID); - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeRemoveItem(SHARED_PREF_EMAIL); + safeRemoveItem(SHARED_PREF_USER_ID); + safeRemoveItem(SHARED_PREF_CONSENT_TIMESTAMP); /* clear fetched in-app messages */ clearMessages(); @@ -1116,17 +1112,12 @@ export function initialize( /* if consent is true, we want to clear unknown user data and start tracking */ if (consent) { unknownUserManager.removeUnknownSessionCriteriaData(); - localStorage.removeItem(SHARED_PREFS_CRITERIA); + safeRemoveItem(SHARED_PREFS_CRITERIA); // Store consent timestamp when user grants consent - const existingConsent = localStorage.getItem( - SHARED_PREF_CONSENT_TIMESTAMP - ); + const existingConsent = safeGetItem(SHARED_PREF_CONSENT_TIMESTAMP); if (!existingConsent) { - localStorage.setItem( - SHARED_PREF_CONSENT_TIMESTAMP, - Date.now().toString() - ); + safeSetItem(SHARED_PREF_CONSENT_TIMESTAMP, Date.now().toString()); } enableUnknownTracking(); } else { @@ -1135,9 +1126,9 @@ export function initialize( if (unknownUsageTracked) { unknownUserManager.removeUnknownSessionCriteriaData(); - localStorage.removeItem(SHARED_PREFS_CRITERIA); - localStorage.removeItem(SHARED_PREF_UNKNOWN_USER_ID); - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeRemoveItem(SHARED_PREFS_CRITERIA); + safeRemoveItem(SHARED_PREF_UNKNOWN_USER_ID); + safeRemoveItem(SHARED_PREF_CONSENT_TIMESTAMP); setTypeOfAuth(null); authIdentifier = null; diff --git a/src/unknownUserTracking/unknownUserEventManager.ts b/src/unknownUserTracking/unknownUserEventManager.ts index 85c4f97b..ee739012 100644 --- a/src/unknownUserTracking/unknownUserEventManager.ts +++ b/src/unknownUserTracking/unknownUserEventManager.ts @@ -34,6 +34,7 @@ import { SHARED_PREF_EMAIL, SHARED_PREF_USER_ID } from '../constants'; +import { safeGetItem, safeSetItem, safeRemoveItem } from '../utils/safeStorage'; import { baseIterableRequest } from '../request'; import { getTypeOfAuth } from '../utils/typeOfAuth'; import { IterableResponse } from '../types'; @@ -103,7 +104,7 @@ export function isUnknownUsageTracked(): boolean { if (!isEnabled) return false; // Also check if user has given consent (consent timestamp exists) - const consentTimestamp = localStorage.getItem(SHARED_PREF_CONSENT_TIMESTAMP); + const consentTimestamp = safeGetItem(SHARED_PREF_CONSENT_TIMESTAMP); return consentTimestamp !== null; } @@ -114,9 +115,7 @@ export class UnknownUserEventManager { if (!unknownUsageTracked) return; - const strUnknownSessionInfo = localStorage.getItem( - SHARED_PREFS_UNKNOWN_SESSIONS - ); + const strUnknownSessionInfo = safeGetItem(SHARED_PREFS_UNKNOWN_SESSIONS); let unknownSessionInfo: { itbl_unknown_sessions?: { number_of_sessions?: number; @@ -145,10 +144,7 @@ export class UnknownUserEventManager { itbl_unknown_sessions: unknownSessionInfo.itbl_unknown_sessions }; - localStorage.setItem( - SHARED_PREFS_UNKNOWN_SESSIONS, - JSON.stringify(outputObject) - ); + safeSetItem(SHARED_PREFS_UNKNOWN_SESSIONS, JSON.stringify(outputObject)); } catch (error) { console.error('Error updating unknown session:', error); } @@ -168,10 +164,7 @@ export class UnknownUserEventManager { .then((response) => { const criteriaData: any = response.data; if (criteriaData) { - localStorage.setItem( - SHARED_PREFS_CRITERIA, - JSON.stringify(criteriaData) - ); + safeSetItem(SHARED_PREFS_CRITERIA, JSON.stringify(criteriaData)); } }) .catch((e) => { @@ -220,11 +213,9 @@ export class UnknownUserEventManager { } private checkCriteriaCompletion(): string | null { - const criteriaData = localStorage.getItem(SHARED_PREFS_CRITERIA); - const localStoredEventList = localStorage.getItem( - SHARED_PREFS_EVENT_LIST_KEY - ); - const localStoredUserUpdate = localStorage.getItem( + const criteriaData = safeGetItem(SHARED_PREFS_CRITERIA); + const localStoredEventList = safeGetItem(SHARED_PREFS_EVENT_LIST_KEY); + const localStoredUserUpdate = safeGetItem( SHARED_PREFS_USER_UPDATE_OBJECT_KEY ); try { @@ -248,17 +239,15 @@ export class UnknownUserEventManager { if (!unknownUsageTracked) return; - let userData = localStorage.getItem(SHARED_PREFS_UNKNOWN_SESSIONS); + let userData = safeGetItem(SHARED_PREFS_UNKNOWN_SESSIONS); // If no session data exists, create it first if (!userData) { this.updateUnknownSession(); - userData = localStorage.getItem(SHARED_PREFS_UNKNOWN_SESSIONS); + userData = safeGetItem(SHARED_PREFS_UNKNOWN_SESSIONS); } - const strUserUpdate = localStorage.getItem( - SHARED_PREFS_USER_UPDATE_OBJECT_KEY - ); + const strUserUpdate = safeGetItem(SHARED_PREFS_USER_UPDATE_OBJECT_KEY); const dataFields = strUserUpdate ? JSON.parse(strUserUpdate) : {}; delete dataFields[SHARED_PREFS_EVENT_TYPE]; @@ -317,14 +306,12 @@ export class UnknownUserEventManager { } async syncEvents() { - const strTrackEventList = localStorage.getItem(SHARED_PREFS_EVENT_LIST_KEY); + const strTrackEventList = safeGetItem(SHARED_PREFS_EVENT_LIST_KEY); const trackEventList = strTrackEventList ? JSON.parse(strTrackEventList) : []; - const strUserUpdate = localStorage.getItem( - SHARED_PREFS_USER_UPDATE_OBJECT_KEY - ); + const strUserUpdate = safeGetItem(SHARED_PREFS_USER_UPDATE_OBJECT_KEY); const userUpdateObject = strUserUpdate ? JSON.parse(strUserUpdate) : {}; if (trackEventList.length) { @@ -387,9 +374,7 @@ export class UnknownUserEventManager { if (replayEnabled) { try { if (isUserKnown === true) { - const unknownUserCreated = localStorage.getItem( - SHARED_PREF_UNKNOWN_USER_ID - ); + const unknownUserCreated = safeGetItem(SHARED_PREF_UNKNOWN_USER_ID); if (!unknownUserCreated) { await this.trackConsent(true); } @@ -407,9 +392,9 @@ export class UnknownUserEventManager { } removeUnknownSessionCriteriaData() { - localStorage.removeItem(SHARED_PREFS_UNKNOWN_SESSIONS); - localStorage.removeItem(SHARED_PREFS_EVENT_LIST_KEY); - localStorage.removeItem(SHARED_PREFS_USER_UPDATE_OBJECT_KEY); + safeRemoveItem(SHARED_PREFS_UNKNOWN_SESSIONS); + safeRemoveItem(SHARED_PREFS_EVENT_LIST_KEY); + safeRemoveItem(SHARED_PREFS_USER_UPDATE_OBJECT_KEY); } private async storeEventListToLocalStorage(newDataObject: UnknownEventData) { @@ -417,7 +402,7 @@ export class UnknownUserEventManager { if (!unknownUsageTracked) return; - const strTrackEventList = localStorage.getItem(SHARED_PREFS_EVENT_LIST_KEY); + const strTrackEventList = safeGetItem(SHARED_PREFS_EVENT_LIST_KEY); let previousDataArray = []; if (strTrackEventList) { @@ -439,10 +424,7 @@ export class UnknownUserEventManager { ); } - localStorage.setItem( - SHARED_PREFS_EVENT_LIST_KEY, - JSON.stringify(previousDataArray) - ); + safeSetItem(SHARED_PREFS_EVENT_LIST_KEY, JSON.stringify(previousDataArray)); const criteriaId = this.checkCriteriaCompletion(); if (criteriaId !== null) { this.createUnknownUser(criteriaId); @@ -456,9 +438,7 @@ export class UnknownUserEventManager { if (!unknownUsageTracked) return; - const strUserUpdate = localStorage.getItem( - SHARED_PREFS_USER_UPDATE_OBJECT_KEY - ); + const strUserUpdate = safeGetItem(SHARED_PREFS_USER_UPDATE_OBJECT_KEY); let userUpdateObject = {}; if (strUserUpdate) { @@ -470,7 +450,7 @@ export class UnknownUserEventManager { ...newDataObject }; - localStorage.setItem( + safeSetItem( SHARED_PREFS_USER_UPDATE_OBJECT_KEY, JSON.stringify(userUpdateObject) ); @@ -547,7 +527,7 @@ export class UnknownUserEventManager { // Consent tracking methods getConsentTimestamp(): string | null { - return localStorage.getItem(SHARED_PREF_CONSENT_TIMESTAMP); + return safeGetItem(SHARED_PREF_CONSENT_TIMESTAMP); } hasConsent(): boolean { @@ -559,21 +539,21 @@ export class UnknownUserEventManager { // First priority: actual user credentials from login/signup if (typeOfAuth === 'email') { - const email = localStorage.getItem(SHARED_PREF_EMAIL); + const email = safeGetItem(SHARED_PREF_EMAIL); if (email) { return { email }; } } if (typeOfAuth === 'userID') { - const userId = localStorage.getItem(SHARED_PREF_USER_ID); + const userId = safeGetItem(SHARED_PREF_USER_ID); if (userId) { return { userId }; } } // Fallback: generated unknown user ID (for scenario 1: after /session call) - const unknownUserId = localStorage.getItem(SHARED_PREF_UNKNOWN_USER_ID); + const unknownUserId = safeGetItem(SHARED_PREF_UNKNOWN_USER_ID); if (unknownUserId) { return { userId: unknownUserId }; } @@ -608,7 +588,7 @@ export class UnknownUserEventManager { }); // Remove consent timestamp after successful call - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeRemoveItem(SHARED_PREF_CONSENT_TIMESTAMP); return response; } catch (error) { diff --git a/src/utils/authorizationToken.ts b/src/utils/authorizationToken.ts index 5b378f1b..f37984b4 100644 --- a/src/utils/authorizationToken.ts +++ b/src/utils/authorizationToken.ts @@ -1,22 +1,23 @@ import { SHARED_PREF_USER_TOKEN } from '../constants'; +import { safeGetItem, safeSetItem, safeRemoveItem } from './safeStorage'; class AuthorizationToken { public token: string | null = null; setToken(token: string) { this.token = token; - localStorage.setItem(SHARED_PREF_USER_TOKEN, token); + safeSetItem(SHARED_PREF_USER_TOKEN, token); } getToken(): string | null { return this.token && this.token.length > 0 ? this.token - : localStorage.getItem(SHARED_PREF_USER_TOKEN); + : safeGetItem(SHARED_PREF_USER_TOKEN); } clearToken() { this.token = null; - localStorage.removeItem(SHARED_PREF_USER_TOKEN); + safeRemoveItem(SHARED_PREF_USER_TOKEN); } } diff --git a/src/utils/functions.ts b/src/utils/functions.ts index a5d81e70..b3ac953a 100644 --- a/src/utils/functions.ts +++ b/src/utils/functions.ts @@ -1,4 +1,5 @@ import { SHARED_PREF_EMAIL, SHARED_PREF_USER_ID } from '../constants'; +import { safeStorage } from './safeStorage'; export default class { private static emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -9,11 +10,11 @@ export default class { public static addEmailOrUserIdToJson( jsonParams: any, - localStorage: Storage + storage: Storage = safeStorage ): any { const store = jsonParams; - const userId = localStorage.getItem(SHARED_PREF_USER_ID); - const email = localStorage.getItem(SHARED_PREF_EMAIL); + const userId = storage.getItem(SHARED_PREF_USER_ID); + const email = storage.getItem(SHARED_PREF_EMAIL); if (userId) { store.userId = userId; } else if (email) { diff --git a/src/utils/safeStorage.test.ts b/src/utils/safeStorage.test.ts new file mode 100644 index 00000000..8fcff023 --- /dev/null +++ b/src/utils/safeStorage.test.ts @@ -0,0 +1,63 @@ +import { safeGetItem, safeSetItem, safeRemoveItem } from './safeStorage'; + +describe('safeStorage', () => { + describe('when localStorage is available', () => { + it('should set and get items', () => { + safeSetItem('test-key', 'test-value'); + expect(safeGetItem('test-key')).toBe('test-value'); + }); + + it('should remove items', () => { + safeSetItem('test-key', 'test-value'); + safeRemoveItem('test-key'); + expect(safeGetItem('test-key')).toBeNull(); + }); + + it('should return null for missing keys', () => { + expect(safeGetItem('nonexistent-key')).toBeNull(); + }); + }); + + describe('when localStorage throws', () => { + const originalLocalStorage = global.localStorage; + + beforeEach(() => { + // Simulate an environment where localStorage throws on access + Object.defineProperty(global, 'localStorage', { + value: { + getItem: () => { + throw new Error('localStorage is not available'); + }, + setItem: () => { + throw new Error('localStorage is not available'); + }, + removeItem: () => { + throw new Error('localStorage is not available'); + } + }, + writable: true, + configurable: true + }); + }); + + afterEach(() => { + Object.defineProperty(global, 'localStorage', { + value: originalLocalStorage, + writable: true, + configurable: true + }); + }); + + it('should not throw on getItem', () => { + expect(() => safeGetItem('any-key')).not.toThrow(); + }); + + it('should not throw on setItem', () => { + expect(() => safeSetItem('any-key', 'value')).not.toThrow(); + }); + + it('should not throw on removeItem', () => { + expect(() => safeRemoveItem('any-key')).not.toThrow(); + }); + }); +}); diff --git a/src/utils/safeStorage.ts b/src/utils/safeStorage.ts new file mode 100644 index 00000000..2627fab5 --- /dev/null +++ b/src/utils/safeStorage.ts @@ -0,0 +1,100 @@ +/** + * Safe localStorage wrapper with feature detection. + * + * In environments where localStorage is unavailable (mobile webviews, + * iOS Safari private browsing, etc.), all operations fall back to an + * in-memory store so the SDK never throws. + */ + +const memoryStore: Record = {}; + +function isLocalStorageAvailable(): boolean { + try { + const testKey = '__iterable_ls_test__'; + localStorage.setItem(testKey, '1'); + localStorage.removeItem(testKey); + return true; + } catch { + return false; + } +} + +const localStorageAvailable = isLocalStorageAvailable(); + +export function safeGetItem(key: string): string | null { + if (localStorageAvailable) { + try { + return localStorage.getItem(key); + } catch { + return memoryStore[key] ?? null; + } + } + return memoryStore[key] ?? null; +} + +export function safeSetItem(key: string, value: string): void { + if (localStorageAvailable) { + try { + localStorage.setItem(key, value); + return; + } catch { + // fall through to memory store + } + } + memoryStore[key] = value; +} + +export function safeRemoveItem(key: string): void { + if (localStorageAvailable) { + try { + localStorage.removeItem(key); + return; + } catch { + // fall through to memory store + } + } + delete memoryStore[key]; +} + +/** + * A Storage-compatible object that can be passed where the SDK + * currently expects a Storage parameter (e.g. functions.ts). + */ +export const safeStorage: Storage = { + get length() { + if (localStorageAvailable) { + try { + return localStorage.length; + } catch { + return Object.keys(memoryStore).length; + } + } + return Object.keys(memoryStore).length; + }, + key(index: number) { + if (localStorageAvailable) { + try { + return localStorage.key(index); + } catch { + return Object.keys(memoryStore)[index] ?? null; + } + } + return Object.keys(memoryStore)[index] ?? null; + }, + getItem: safeGetItem, + setItem: safeSetItem, + removeItem: safeRemoveItem, + clear() { + if (localStorageAvailable) { + try { + localStorage.clear(); + return; + } catch { + // fall through + } + } + Object.keys(memoryStore).forEach((key) => { + delete memoryStore[key]; + }); + } +};