import { Platform } from "react-native"; import { AppStateMonitor } from "./core/AppStateMonitor"; import { SessionManager } from "./core/SessionManager"; import { SsoClient } from "./core/SsoClient"; import { getDefaultDeviceContext } from "./context/device"; import { CONSTANTS } from "./constants"; import { DataTransport } from "./dataTransport"; import { NotificationEngine } from "./modules/notifications"; import { DEFAULT_SIRRUS_IOS_CATEGORIES, NativeNotificationPresenter, PushManager, } from "./modules/push"; import { consumeInitialNativeNotificationEvent, isSirrusNativeNotificationsAvailable, registerNativeAndroidChannels, registerNativeIOSCategories, subscribeToNativeNotificationEvents, } from "./native"; import { InMemoryStorageAdapter, StorageManager, } from "./storage"; import type { AndroidNotificationChannelConfig, DeviceContext, EventContext, EventPayload, IOSNotificationCategoryConfig, JsonValue, NativeNotificationEvent, NotificationInteractionEvent, NotificationInteractionListener, NotificationClassification, NotificationProcessingResult, NotificationUiConfig, PiiData, PushNotificationAction, PushNotificationPayload, PushTokenRegistrationInput, SDKConfig, TrackOptions, UserInfo, } from "./types"; import { createId } from "./utils/ids"; import { encryptPii } from "./utils/pii-encryptor"; import { Logger } from "./utils/logger"; import { getBaseUrl } from "./utils/url"; export class SirrusReactNativeSDK { private initialized = false; private logger = new Logger(false); private storage = new StorageManager(new InMemoryStorageAdapter()); private sessionManager = new SessionManager(this.storage, this.logger); private appStateMonitor = new AppStateMonitor(); private transport: DataTransport | null = null; private pushManager: PushManager | null = null; private notificationEngine: NotificationEngine | null = null; private currentConfig: SDKConfig | null = null; private currentApiKey: string | null = null; private ssoUrl: string = CONSTANTS.ssoProdUrl; private userInfo: UserInfo | null = null; private nativeNotificationUnsubscribe: (() => void) | null = null; private initPromise: Promise | null = null; private readonly nativeNotificationEventDedupeWindowMs = 30000; private readonly handledNativeNotificationEvents = new Map(); private readonly notificationInteractionListeners = new Set(); private readonly pendingNotificationInteractions: NotificationInteractionEvent[] = []; private readonly maxPendingNotificationInteractions = 25; async init(config: SDKConfig): Promise { if (this.initPromise) { this.logger.info("Init already in progress. Waiting for existing init."); await this.initPromise; } if (this.initialized && this.currentApiKey === config.apiKey) { this.logger.info("SDK already initialized for this API key"); return; } const initTask = this.initInternal(config); this.initPromise = initTask; try { await initTask; } finally { if (this.initPromise === initTask) { this.initPromise = null; } } } isInitialized(): boolean { return this.initialized && !!this.transport; } async whenInitialized(): Promise { await this.ensureInitialized(); } private async initInternal(config: SDKConfig): Promise { if (this.initialized) { this.logger.info("Reinitializing SDK. Shutting down existing instance first."); await this.shutdown(); } this.logger = new Logger(config.debug ?? false); this.logger.info("Initialising SDK - calling SSO init"); if (!config.storage) { this.logger.warn( "No persistent storage adapter provided. Use AsyncStorage/MMKV storage in production so IDs and queued events survive app restarts." ); } this.storage = new StorageManager(config.storage ?? new InMemoryStorageAdapter()); this.sessionManager = new SessionManager( this.storage, this.logger, async () => { await this.trackInternal("session_start", { sessionStartTime: new Date().toISOString(), }); } ); this.userInfo = config.userInfo ? this.normalizeUserInfo(config.userInfo) : await this.loadUserInfo(); if (config.userInfo) { await this.persistUserInfo(this.userInfo); } const initialSsoUrl = config.ssoUrl ?? CONSTANTS.ssoProdUrl; this.ssoUrl = initialSsoUrl; const fetcher = config.networkClient ?? fetch; const ssoClient = new SsoClient(fetcher, this.ssoUrl, config.endpoints); const ssoResponse = await ssoClient.init(config.apiKey); const notificationUrl = this.firstString( ssoResponse.notificationUrl, ssoResponse.notificationURL, ssoResponse.notification_url, ssoResponse.notificationsUrl, ssoResponse.notifications_url, ssoResponse.pushEventsUrl, ssoResponse.push_events_url, ssoResponse.config?.env?.notifications, ssoResponse.config?.env?.notificationUrl, ssoResponse.config?.env?.notification_url, ssoResponse.config?.env?.pushEventsUrl, ssoResponse.config?.env?.push_events_url ); const piiPublicKey = this.firstString( ssoResponse.PII_PublicKey, (ssoResponse as { piiPublicKey?: string }).piiPublicKey, (ssoResponse as { pii_public_key?: string }).pii_public_key ); this.logger.info("SSO init succeeded", { orgId: ssoResponse.orgId, projectId: ssoResponse.projectId, ingestionUrl: ssoResponse.ingestionUrl, notificationUrl, hasPiiPublicKey: Boolean(piiPublicKey), responseKeys: Object.keys(ssoResponse), expiresAt: ssoResponse.expiresAt, }); if ( ssoResponse.config?.env?.auth && this.shouldUseServerEnvironmentUrl(ssoResponse.config.env.auth, initialSsoUrl) ) { this.ssoUrl = ssoResponse.config.env.auth; this.logger.info("Using SSO auth URL from server config", { ssoUrl: this.ssoUrl, }); } else if (ssoResponse.config?.env?.auth) { this.logger.warn("Ignored loopback SSO auth URL from server config", { serverAuthUrl: ssoResponse.config.env.auth, initialSsoUrl, }); } this.sessionManager.configure(ssoResponse); const baseApiUrl = ssoResponse.config?.env?.api ?? getBaseUrl(ssoResponse.ingestionUrl); this.logger.info("Resolved SDK service endpoints", { ingestionUrl: ssoResponse.ingestionUrl, notificationUrl, baseApiUrl, }); let jwtRefreshPromise: Promise | null = null; const refreshJwt = async (): Promise => { if (jwtRefreshPromise) { return jwtRefreshPromise; } const refreshTask = (async () => { this.logger.info("Refreshing SSO JWT"); const refreshClient = new SsoClient(fetcher, this.ssoUrl, config.endpoints); const newJwt = await refreshClient.refresh( this.transport?.getJwt() ?? ssoResponse.jwt ); if (newJwt) { this.transport?.updateJwt(newJwt); } return newJwt; })(); jwtRefreshPromise = refreshTask; try { return await refreshTask; } finally { if (jwtRefreshPromise === refreshTask) { jwtRefreshPromise = null; } } }; const reinitializeAfterAuthFailure = async (): Promise => { if (!this.currentConfig) { return; } this.logger.warn("SDK reinitialization requested for auth recovery"); this.transport?.destroy(); this.transport = null; this.initialized = false; await this.init(this.currentConfig); }; this.transport = new DataTransport( { endpoint: ssoResponse.ingestionUrl, apiKey: config.apiKey, jwt: ssoResponse.jwt, orgId: ssoResponse.orgId, projectId: ssoResponse.projectId, piiPublicKey, fetcher, piiEncryptor: config.piiEncryptor ?? encryptPii, onError: config.onError, onJwtRefresh: refreshJwt, onReinit: reinitializeAfterAuthFailure, }, this.storage, this.logger, ssoResponse.config?.maxBatchSize ?? CONSTANTS.defaults.batchSize, ssoResponse.config?.flushIntervalMs ?? CONSTANTS.defaults.flushIntervalMs, ssoResponse.config?.maxEventsPerMinute ?? CONSTANTS.defaults.maxEventsPerMinute ); await this.transport.ready(); this.logger.info("Event transport ready"); await this.sessionManager.init(); this.logger.info("Session manager ready"); const notificationConfig = this.resolveNotificationUiConfig( config.notificationUI ); const notificationPresenter = notificationConfig.presenter ?? (notificationConfig.useNativePresenter === false || !isSirrusNativeNotificationsAvailable() ? undefined : new NativeNotificationPresenter( notificationConfig.defaultAndroidChannelId )); await registerNativeAndroidChannels( notificationConfig.androidChannels ?? [] ).catch((error) => { this.reportNonBlockingStartupError( "Sirrus Android notification channel registration failed", error, config ); }); const iosCategories = [ ...DEFAULT_SIRRUS_IOS_CATEGORIES, ...(notificationConfig.iosCategories ?? []), ]; await registerNativeIOSCategories(iosCategories).catch((error) => { this.reportNonBlockingStartupError( "Sirrus iOS notification category registration failed", error, config ); }); this.logger.info("Native notification configuration completed", { androidChannelCount: notificationConfig.androidChannels?.length ?? 0, iosCategoryCount: iosCategories.length, nativeNotificationsAvailable: isSirrusNativeNotificationsAvailable(), }); this.pushManager = new PushManager( { apiKey: config.apiKey, fetcher, getJwt: () => this.transport?.getJwt() ?? ssoResponse.jwt, onJwtRefresh: refreshJwt, onReinit: reinitializeAfterAuthFailure, notificationUrl, notificationPresenter, organisationId: ssoResponse.orgId, getUserInfo: () => this.getUserInfo(), }, this.logger ); this.logger.info("Push notification analytics configured", { hasNotificationUrl: Boolean(notificationUrl), notificationUrl, }); this.notificationEngine = new NotificationEngine( this, this.logger, notificationConfig ); this.currentConfig = config; this.currentApiKey = config.apiKey; this.initialized = true; this.nativeNotificationUnsubscribe?.(); this.nativeNotificationUnsubscribe = subscribeToNativeNotificationEvents( (event) => { void this.handleNativeNotificationEvent(event); } ); this.appStateMonitor.start((status) => { this.logger.info("App state changed", { status }); if (status === "active") { void this.handleAppBecameActive(); } else if (status === "background" || status === "inactive") { this.transport?.flush(); } }); void this.runPostInitStartupEffects(config); this.logger.info("SDK initialised successfully"); } async setUserInfo(userInfo: UserInfo | null): Promise { await this.ensureInitialized(); await this.setUserInfoInternal(userInfo); } getUserInfo(): UserInfo | null { return this.userInfo ? { ...this.userInfo } : null; } setPii(data: PiiData | null): void { this.assertInitialized(); this.transport?.setPii(data); this.logger.info(data ? "PII data set" : "PII data cleared"); } async track( eventName: string, properties?: Record, options: TrackOptions = {} ): Promise { await this.ensureInitialized(); this.logger.info("Tracking event", { eventName, immediate: options.immediate ?? false, }); await this.sessionManager.renewSessionIfNeeded(); await this.trackInternal(eventName, properties, options.context, options.immediate); } async screen( screenName: string, properties?: Record ): Promise { await this.track("screen_view", { screenName, ...properties, }); } async registerToken(input: PushTokenRegistrationInput): Promise { await this.ensureInitialized(); this.assertPushTokenRegistrationInput(input); await this.sessionManager.renewSessionIfNeeded(); await this.setUserInfoInternal({ userId: input.userId, phone: input.phone, }); const deviceContext = await this.getDeviceContext(); const event = await this.buildEvent( "userToken", { userId: input.userId, phone: input.phone, userToken: input.token, device: deviceContext.platform, }, { device: deviceContext, } ); this.logger.info("Sending push token to ingestion API", { userId: input.userId, eventId: event.id, }); await this.transport?.sendImmediately(event); await this.sessionManager.updateActivity(); this.logger.info("Push token sent to ingestion API", { eventId: event.id, }); } async handlePushReceived(payload: PushNotificationPayload): Promise { await this.processNotificationReceived(payload); } async handlePushOpened(payload: PushNotificationPayload): Promise { await this.processNotificationOpened(payload); } classifyNotification( payload: PushNotificationPayload ): NotificationClassification { this.assertInitialized(); return ( this.notificationEngine?.classify(payload) ?? { matched: false, ownership: "unknown", reason: "Notification engine is not initialized.", } ); } isSirrusNotification(payload: PushNotificationPayload): boolean { return this.classifyNotification(payload).matched; } onNotificationInteraction( listener: NotificationInteractionListener ): () => void { this.notificationInteractionListeners.add(listener); const pendingInteractions = this.pendingNotificationInteractions.splice(0); for (const event of pendingInteractions) { this.invokeNotificationInteractionListener(listener, event); } return () => { this.notificationInteractionListeners.delete(listener); }; } async processNotificationReceived( payload: PushNotificationPayload, options: { displayForegroundNotification?: boolean; displaySuppressionReason?: string; } = {} ): Promise { await this.ensureInitialized(); this.logger.info("Sirrus notification receive payload", { payload, }); return ( (await this.notificationEngine?.processReceived(payload, options)) ?? { handled: false, displayed: false, opened: false, classification: { matched: false, ownership: "unknown", reason: "Notification engine is not initialized.", }, } ); } async processNotificationOpened( payload: PushNotificationPayload ): Promise { await this.ensureInitialized(); this.logger.info("Sirrus notification open payload", { payload, }); const result = (await this.notificationEngine?.processOpened(payload)) ?? { handled: false, displayed: false, opened: false, classification: { matched: false, ownership: "unknown", reason: "Notification engine is not initialized.", }, }; if (result.handled && result.opened) { this.notifyNotificationInteractionListeners({ type: "opened", payload, deeplinkUrl: payload.deeplinkUrl, }); } return result; } async showNotification(payload: PushNotificationPayload): Promise { await this.ensureInitialized(); this.logger.info("Showing notification manually", { campaignId: payload.campaignId, messageId: payload.messageId, hasImage: Boolean(payload.imageUrl), hasGif: Boolean(payload.gifUrl), hasVideo: Boolean(payload.videoUrl), }); await this.pushManager?.showNotification(payload, "manual"); } async registerAndroidNotificationChannels( channels: AndroidNotificationChannelConfig[] ): Promise { await registerNativeAndroidChannels(channels); } async registerIOSNotificationCategories( categories: IOSNotificationCategoryConfig[] ): Promise { await registerNativeIOSCategories(categories); } async flush(): Promise { await this.ensureInitialized(); this.logger.info("Manual flush requested"); await this.transport?.flushAndWait(); } async shutdown(): Promise { if (this.initPromise) { await this.initPromise.catch(() => undefined); } if (!this.initialized) { return; } this.logger.info("Shutting down SDK"); await this.trackInternal("session_end", { sessionEndTime: new Date().toISOString(), }); await this.transport?.flushAndWait(); this.transport?.destroy(); await this.sessionManager.clearSession(); this.appStateMonitor.stop(); this.nativeNotificationUnsubscribe?.(); this.nativeNotificationUnsubscribe = null; this.handledNativeNotificationEvents.clear(); this.initialized = false; this.currentApiKey = null; this.transport = null; this.pushManager = null; this.notificationEngine = null; this.logger.info("SDK shutdown complete"); } private async getDeviceContext(): Promise { const baseContext = getDefaultDeviceContext(this.currentConfig?.appInfo); const override = await this.currentConfig?.deviceContextProvider?.(); return { ...baseContext, ...override, app: { ...baseContext.app, ...(override?.app ?? {}), }, screen: { ...baseContext.screen, ...(override?.screen ?? {}), }, }; } private async runPostInitStartupEffects(config: SDKConfig): Promise { try { await this.consumePendingNativeNotificationEvents(); await this.handleAppBecameActive(); } catch (error) { const normalized = error instanceof Error ? error : new Error(String(error)); this.logger.warn("Sirrus post-init startup effect failed", normalized); await config.onError?.(normalized); } } private reportNonBlockingStartupError( message: string, error: unknown, config: SDKConfig ): void { const normalized = error instanceof Error ? error : new Error(String(error)); this.logger.warn(message, normalized); void config.onError?.(normalized); } private shouldUseServerEnvironmentUrl( candidateUrl: string, initialUrl: string ): boolean { if (!this.isLoopbackUrl(candidateUrl)) { return true; } return this.isLoopbackUrl(initialUrl); } private isLoopbackUrl(value: string): boolean { try { const hostname = new URL(value).hostname.toLowerCase(); return ( hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ); } catch { return false; } } private resolveNotificationUiConfig( config: NotificationUiConfig = {} ): NotificationUiConfig { const defaultAndroidChannelId = config.defaultAndroidChannelId ?? "sirrus-marketing"; const defaultAndroidChannels: AndroidNotificationChannelConfig[] = [ { id: defaultAndroidChannelId, name: "Sirrus Marketing", importance: "high", }, ]; const androidChannels = Platform.OS === "android" ? this.mergeAndroidChannels([ ...defaultAndroidChannels, ...this.androidChannelIdsToConfigs(config.androidChannelIds), ...(config.androidChannels ?? []), ]) : config.androidChannels ?? []; return { ...config, handlingMode: config.handlingMode ?? "sirrus-only", showForegroundNotifications: config.showForegroundNotifications ?? (Platform.OS === "ios"), useNativePresenter: config.useNativePresenter ?? true, defaultAndroidChannelId, androidChannels, }; } private androidChannelIdsToConfigs( channelIds: string[] | undefined ): AndroidNotificationChannelConfig[] { return (channelIds ?? []) .map((id) => (typeof id === "string" ? id.trim() : "")) .filter((id) => id.length > 0) .map((id) => ({ id, name: id, importance: "high" as const, })); } private mergeAndroidChannels( channels: AndroidNotificationChannelConfig[] ): AndroidNotificationChannelConfig[] { const merged = new Map(); for (const channel of channels) { const id = typeof channel.id === "string" ? channel.id.trim() : ""; if (!id) { continue; } const name = typeof channel.name === "string" ? channel.name.trim() : ""; merged.set(id, { ...channel, id, name: name || id, }); } return [...merged.values()]; } private async buildEvent( eventName: string, properties?: Record, contextOverride?: Partial ): Promise { return { id: createId(), apiVersion: CONSTANTS.version, event_name: eventName, timestamp: new Date().toISOString(), visitor_id: await this.sessionManager.getVisitorId(), installation_id: await this.sessionManager.getInstallationId(), session_id: await this.sessionManager.getSessionId(), userId: this.userInfo?.userId, phone: this.userInfo?.phone, org_id: this.sessionManager.getOrgId(), project_id: this.sessionManager.getProjectId(), properties: properties ?? {}, context: { device: await this.getDeviceContext(), ...contextOverride, }, }; } private async trackInternal( eventName: string, properties?: Record, contextOverride?: Partial, immediate = false ): Promise { const event = await this.buildEvent(eventName, properties, contextOverride); await this.transport?.enqueue(event, !immediate); this.logger.info("Event added to transport", { eventName, eventId: event.id, immediate, }); await this.sessionManager.updateActivity(); if (immediate) { this.logger.info("Immediate event requested. Flushing now.", { eventName, eventId: event.id, }); await this.transport?.flushAndWait(); } } private assertInitialized(): void { if (!this.isInitialized()) { throw new Error("Sirrus React Native SDK has not been initialized yet."); } } private async ensureInitialized(): Promise { if (this.isInitialized()) { return; } if (this.initPromise) { await this.initPromise; } this.assertInitialized(); } async handlePushReceivedInternally( payload: PushNotificationPayload ): Promise { await this.ensureInitialized(); await this.pushManager?.handleNotificationReceived(payload); } async handlePushOpenedInternally( payload: PushNotificationPayload ): Promise { await this.ensureInitialized(); await this.pushManager?.handleNotificationOpened(payload); } async handlePushActionClickedInternally( payload: PushNotificationPayload, action?: PushNotificationAction ): Promise { await this.ensureInitialized(); await this.pushManager?.handleNotificationActionClicked(payload, action); } async showNotificationInternally( payload: PushNotificationPayload ): Promise { await this.ensureInitialized(); await this.pushManager?.showNotification(payload, "foreground_receive"); } private async handleNativeNotificationEvent( event: NativeNotificationEvent ): Promise { if (this.shouldSkipNativeNotificationEvent(event)) { this.logger.info("Skipping duplicate native notification event", { type: event.type, actionId: event.actionId, messageId: event.payload.messageId, }); return; } this.logger.info("Sirrus native notification event received", { type: event.type, actionId: event.actionId, action: event.action, payload: event.payload, }); const payload: PushNotificationPayload = { ...event.payload, deeplinkUrl: event.deeplinkUrl || event.payload.deeplinkUrl, }; if (event.type === "received") { if (event.displayedBySystem) { this.logger.info("Sirrus foreground notification presented by iOS", { campaignId: payload.campaignId, messageId: payload.messageId, }); } await this.processNotificationReceived(payload, { displayForegroundNotification: !event.displayedBySystem, displaySuppressionReason: event.displayedBySystem ? "displayed_by_ios" : undefined, }).catch((error) => { this.logger.warn("Failed to process native notification receive", error); }); return; } const clickedAction = this.resolveNotificationAction(event, payload); const actionId = clickedAction?.id ?? event.actionId; if (event.type === "action_pressed") { const classification = this.classifyNotification(payload); if (!classification.matched) { this.logger.info("Ignoring non-Sirrus notification action", classification); return; } const action = clickedAction ?? (actionId ? { id: actionId, title: actionId, } : undefined); await this.handlePushActionClickedInternally( payload, action ).catch((error) => { this.logger.warn("Failed to process native notification action", error); }); this.notifyNotificationInteractionListeners({ type: "action_pressed", payload, actionId, action, deeplinkUrl: action?.deeplinkUrl ?? event.deeplinkUrl ?? payload.deeplinkUrl, }); return; } await this.processNotificationOpened(payload).catch((error) => { this.logger.warn("Failed to process native notification interaction", error); return null; }); } private notifyNotificationInteractionListeners( event: NotificationInteractionEvent ): void { if (this.notificationInteractionListeners.size === 0) { this.pendingNotificationInteractions.push(event); if ( this.pendingNotificationInteractions.length > this.maxPendingNotificationInteractions ) { this.pendingNotificationInteractions.shift(); } this.logger.info("Queued Sirrus notification interaction for listener", { type: event.type, actionId: event.actionId, messageId: event.payload.messageId, }); return; } for (const listener of [...this.notificationInteractionListeners]) { this.invokeNotificationInteractionListener(listener, event); } } private invokeNotificationInteractionListener( listener: NotificationInteractionListener, event: NotificationInteractionEvent ): void { Promise.resolve() .then(() => listener(event)) .catch((error) => { this.logger.warn("Sirrus notification interaction listener failed", error); }); } private shouldSkipNativeNotificationEvent(event: NativeNotificationEvent): boolean { const eventKey = this.nativeNotificationEventKey(event); if (!eventKey) { return false; } const now = Date.now(); this.pruneHandledNativeNotificationEvents(now); const handledAt = this.handledNativeNotificationEvents.get(eventKey); if ( handledAt && now - handledAt < this.nativeNotificationEventDedupeWindowMs ) { return true; } this.handledNativeNotificationEvents.set(eventKey, now); return false; } private nativeNotificationEventKey( event: NativeNotificationEvent ): string | undefined { const messageId = event.payload.messageId; if (!messageId) { return undefined; } return [ event.type, messageId, event.actionId ?? event.action?.id ?? "", ].join(":"); } private pruneHandledNativeNotificationEvents(now: number): void { for (const [eventKey, handledAt] of this.handledNativeNotificationEvents) { if (now - handledAt > this.nativeNotificationEventDedupeWindowMs) { this.handledNativeNotificationEvents.delete(eventKey); } } } private resolveNotificationAction( event: NativeNotificationEvent, payload: PushNotificationPayload ): PushNotificationAction | undefined { const nativeAction = this.normalizeNotificationAction(event.action); const actionId = nativeAction?.id ?? event.actionId; const matchedAction = actionId ? payload.actions?.find((action) => this.notificationActionMatches(action, actionId)) : undefined; if (!nativeAction && !matchedAction && !actionId) { return undefined; } return { ...(matchedAction ?? {}), ...(nativeAction ?? {}), id: nativeAction?.id ?? matchedAction?.id ?? actionId ?? "", title: nativeAction?.title ?? matchedAction?.title ?? nativeAction?.label ?? matchedAction?.label ?? actionId ?? "", label: nativeAction?.label ?? matchedAction?.label ?? nativeAction?.title ?? matchedAction?.title, actionType: nativeAction?.actionType ?? matchedAction?.actionType, postback: nativeAction?.postback ?? matchedAction?.postback, deeplinkUrl: nativeAction?.deeplinkUrl ?? matchedAction?.deeplinkUrl, foreground: nativeAction?.foreground ?? matchedAction?.foreground, destructive: nativeAction?.destructive ?? matchedAction?.destructive, authenticationRequired: nativeAction?.authenticationRequired ?? matchedAction?.authenticationRequired, }; } private normalizeNotificationAction(value: unknown): PushNotificationAction | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } const action = value as Record; const id = this.firstString( action.id, action.actionId, action.action_id, action.identifier, action.postback, action.actionType ); const title = this.firstString(action.title, action.label, action.name, action.text, id); if (!id && !title) { return undefined; } return { id: id ?? title ?? "", title: title ?? id ?? "", label: this.firstString(action.label, action.title, action.name, action.text), actionType: this.firstString(action.actionType), postback: this.firstString(action.postback), deeplinkUrl: this.firstString( action.deeplinkUrl, action.deepLinkUrl, action.deep_link_url ), foreground: this.booleanValue(action.foreground), destructive: this.booleanValue(action.destructive), authenticationRequired: this.booleanValue( action.authenticationRequired ?? action.authentication_required ), }; } private notificationActionMatches( action: PushNotificationAction, actionId: string ): boolean { return [ action.id, action.postback, action.actionType, ].some((candidate) => candidate === actionId); } private firstString(...values: unknown[]): string | undefined { for (const value of values) { if (typeof value === "string" && value.trim().length > 0) { return value.trim(); } } return undefined; } private booleanValue(value: unknown): boolean | undefined { if (typeof value === "boolean") { return value; } if (typeof value === "number") { return value !== 0; } if (typeof value === "string") { if (value === "1" || value.toLowerCase() === "true") { return true; } if (value === "0" || value.toLowerCase() === "false") { return false; } } return undefined; } private async handleAppBecameActive(): Promise { await this.consumePendingNativeNotificationEvents(); await this.sessionManager.renewSessionIfNeeded(); await this.sessionManager.updateActivity(); } private async consumePendingNativeNotificationEvents(): Promise { for (let index = 0; index < 25; index += 1) { const pendingNotificationEvent = await consumeInitialNativeNotificationEvent().catch(() => null); if (!pendingNotificationEvent) { return; } this.logger.info("Processing pending native notification event", { type: pendingNotificationEvent.type, actionId: pendingNotificationEvent.actionId, messageId: pendingNotificationEvent.payload?.messageId, }); await this.handleNativeNotificationEvent(pendingNotificationEvent); } } private assertPushTokenRegistrationInput( input: PushTokenRegistrationInput ): void { if (!input || typeof input !== "object") { throw new Error( "registerToken requires an object with token, userId, and phone." ); } const missingFields = [ ["token", input.token], ["userId", input.userId], ["phone", input.phone], ].filter(([, value]) => typeof value !== "string" || value.trim().length === 0); if (missingFields.length > 0) { throw new Error( `registerToken missing required field(s): ${missingFields .map(([field]) => field) .join(", ")}` ); } } private async loadUserInfo(): Promise { const serialized = await this.storage.get(CONSTANTS.storageKeys.userInfo); if (!serialized) { return null; } try { return this.normalizeUserInfo(JSON.parse(serialized) as UserInfo); } catch { await this.storage.remove(CONSTANTS.storageKeys.userInfo); this.logger.warn("Stored Sirrus user info was invalid and has been cleared."); return null; } } private async setUserInfoInternal(userInfo: UserInfo | null): Promise { this.userInfo = userInfo ? this.normalizeUserInfo(userInfo) : null; await this.persistUserInfo(this.userInfo); this.logger.info(this.userInfo ? "Sirrus user info set" : "Sirrus user info cleared", { userId: this.userInfo?.userId, hasPhone: Boolean(this.userInfo?.phone), }); } private async persistUserInfo(userInfo: UserInfo | null): Promise { if (!userInfo) { await this.storage.remove(CONSTANTS.storageKeys.userInfo); return; } await this.storage.set( CONSTANTS.storageKeys.userInfo, JSON.stringify(userInfo) ); } private normalizeUserInfo(userInfo: UserInfo): UserInfo { const normalized = { userId: userInfo.userId.trim(), phone: userInfo.phone.trim(), }; if (!normalized.userId || !normalized.phone) { throw new Error("Sirrus user info requires both userId and phone."); } return normalized; } } export const sirrusReactNativeSDK = new SirrusReactNativeSDK();