UNPKG

@telnyx/react-native-voice-sdk

Version:

Telnyx React Native Voice SDK - A complete WebRTC voice calling solution

1,338 lines (1,156 loc) 59.1 kB
import NetInfo, { NetInfoState, NetInfoSubscription } from '@react-native-community/netinfo'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { EventEmitter } from 'eventemitter3'; import log from 'loglevel'; import uuid from 'uuid-random'; import { Platform } from 'react-native'; import { Call } from './call'; import type { CallOptions } from './call-options'; import type { ClientOptions } from './client-options'; import type { CallReportConfig } from './call-report-models'; import { DEFAULT_CALL_REPORT_CONFIG } from './call-report-models'; import { Connection } from './connection'; import { setSDKVersion } from './env'; import { KeepAliveHandler } from './keep-alive-handler'; import { eventBus } from './legacy-event-bus'; import { LoginHandler } from './login-handler'; import type { InviteEvent, AnswerEvent, CandidateEvent, EndOfCandidatesEvent, } from './messages/call'; import { isInviteEvent, isAnswerEvent, isCandidateEvent, isEndOfCandidatesEvent, getCallIdFromTrickleParams, } from './messages/call'; import { TelnyxRTCMethod } from './messages/methods'; import { createAttachCallMessage } from './messages/attach'; import type { AttachEvent } from './messages/attach'; import { isAttachEvent } from './messages/attach'; import type { MediaEvent } from './messages/media'; import { isMediaEvent } from './messages/media'; import { isValidGatewayStateResponse } from './messages/gateway'; import { from } from 'rxjs'; type TelnyxRTCEvents = { 'telnyx.client.ready': () => void; 'telnyx.client.error': (error: Error) => void; 'telnyx.call.incoming': (call: Call, msg: InviteEvent) => void; 'telnyx.call.reattached': (call: Call, msg: AttachEvent) => void; 'telnyx.media.received': (msg: MediaEvent) => void; 'telnyx.call.answered': (call: Call, msg: AnswerEvent) => void; 'telnyx.call.stateChanged': (call: Call, state: string) => void; 'telnyx.call.removed': (callId: string) => void; }; type PendingTrickleEvent = | { type: 'candidate'; msg: CandidateEvent; receivedAt: number } | { type: 'endOfCandidates'; msg: EndOfCandidatesEvent; receivedAt: number }; export class TelnyxRTC extends EventEmitter<TelnyxRTCEvents> { public options: ClientOptions; public sessionId: string | null; /** * Dictionary of all active calls tracked by their UUIDs. * Calls are tracked from creation until they transition to the 'ended' state. * This matches the iOS SDK behavior where `calls: [UUID: Call]` is used. */ public calls: Map<string, Call> = new Map(); /** * Store listener references for proper cleanup to prevent memory leaks. * Maps call IDs to their state change listener functions. */ private callStateListeners: Map<string, (call: Call, state: string) => void> = new Map(); /** * Returns the current/first active call for backward compatibility. * New code should use `calls` Map or `getCall(callId)` instead. * @deprecated Use `calls` or `getCall(callId)` for multi-call support */ public get call(): Call | null { // Return the first active call (non-ended) or null for (const call of this.calls.values()) { if (call.state !== 'ended') { return call; } } return null; } private connection: Connection | null; private loginHandler: LoginHandler | null; private keepAliveHandler: KeepAliveHandler | null; private netInfoSubscription: NetInfoSubscription | null = null; // Network reconnection state private reconnecting: boolean = false; private lastNetworkType: string | null = null; // Track network type changes private credentialSessionConfig: any = null; // Store login credentials for reconnection private tokenSessionConfig: any = null; // Store login token for reconnection private reconnectionTimeoutHandle: any = null; private reconnectionSessionId: string | null = null; // Store sessionId during reconnection private static readonly RECONNECT_DELAY = 3000; // 3 seconds private static readonly RECONNECT_TIMEOUT = 60000; // 30 seconds // Push notification support private isCallFromPush: boolean = false; private pushNotificationPayload: any = null; private pendingInvite: InviteEvent | null = null; // Early media/ringback support private pendingMediaEvents: Map<string, MediaEvent> = new Map(); private pendingTrickleEvents: Map<string, PendingTrickleEvent[]> = new Map(); private static readonly PENDING_TRICKLE_TTL_MS = 30000; // Pending call actions (matching iOS SDK behavior) private pendingAnswerAction: boolean = false; private pendingEndAction: boolean = false; private pendingCustomHeaders: Record<string, string> = {}; // AsyncStorage keys for persisting push state private static readonly PUSH_STATE_KEY = '@telnyx_push_state'; private static readonly PUSH_VOICE_SDK_ID_KEY = '@telnyx_push_voice_sdk_id'; /** * Save push notification state to AsyncStorage */ private async savePushState() { try { const pushState = { isCallFromPush: this.isCallFromPush, pushNotificationPayload: this.pushNotificationPayload, pushNotificationCallKitUUID: this.pushNotificationCallKitUUID, voice_sdk_id: (this as any)._pushVoiceSDKId, timestamp: Date.now(), }; await AsyncStorage.setItem(TelnyxRTC.PUSH_STATE_KEY, JSON.stringify(pushState)); log.debug('[TelnyxRTC] Saved push state to AsyncStorage:', pushState); } catch (error) { log.error('[TelnyxRTC] Failed to save push state to AsyncStorage:', error); } } /** * Load push notification state from AsyncStorage */ private async loadPushState() { try { const pushStateJson = await AsyncStorage.getItem(TelnyxRTC.PUSH_STATE_KEY); if (!pushStateJson) { log.debug('[TelnyxRTC] No saved push state found'); return; } const pushState = JSON.parse(pushStateJson); // Only restore if state is recent (within last 30 seconds to handle fresh push notifications) const ageMs = Date.now() - (pushState.timestamp || 0); if (ageMs > 30000) { log.debug('[TelnyxRTC] Saved push state is too old, ignoring'); await this.clearPushState(); return; } this.isCallFromPush = pushState.isCallFromPush || false; this.pushNotificationPayload = pushState.pushNotificationPayload || null; this.pushNotificationCallKitUUID = pushState.pushNotificationCallKitUUID || null; (this as any)._pushVoiceSDKId = pushState.voice_sdk_id || null; log.debug('[TelnyxRTC] Restored push state from AsyncStorage:', pushState); console.log('[TelnyxRTC] RELEASE DEBUG - Restored push state from AsyncStorage:', pushState); } catch (error) { log.error('[TelnyxRTC] Failed to load push state from AsyncStorage:', error); } } /** * Clear push notification state from AsyncStorage */ private async clearPushState() { try { await AsyncStorage.removeItem(TelnyxRTC.PUSH_STATE_KEY); log.debug('[TelnyxRTC] Cleared push state from AsyncStorage'); } catch (error) { log.error('[TelnyxRTC] Failed to clear push state from AsyncStorage:', error); } } constructor(opts: ClientOptions) { super(); this.options = opts; if (opts.sdkVersion) { setSDKVersion(opts.sdkVersion); } this.connection = null; this.sessionId = null; this.loginHandler = null; this.keepAliveHandler = null; // calls Map is initialized in class declaration // Initialize pending actions this.pendingAnswerAction = false; this.pendingEndAction = false; this.pendingCustomHeaders = {}; // Force debug logging in development or when explicitly requested const shouldDebug = __DEV__ || opts.logLevel === 'debug' || (global as any).__TELNYX_DEBUG__; const logLevel = shouldDebug ? 'debug' : opts.logLevel || 'warn'; log.setLevel(logLevel); console.log( `🔧 TelnyxRTC: Log level set to '${logLevel}' (dev: ${__DEV__}, requested: ${opts.logLevel})` ); // Initialize lastNetworkType with current network state try { NetInfo.fetch() .then((state) => { this.lastNetworkType = state.type; log.debug('[TelnyxRTC] Initial network type set to:', state.type); }) .catch((error) => { log.warn('[TelnyxRTC] Failed to fetch initial network state:', error); }); this.netInfoSubscription = NetInfo.addEventListener(this.onNetInfoStateChange); } catch (error) { log.warn('[TelnyxRTC] NetInfo not available, network monitoring disabled:', error); } } // ============================================================================ // MARK: - Call Management (Multi-call support matching iOS SDK) // ============================================================================ /** * Access any active call tracked by the SDK. * A call will be accessible until it has ended (transitioned to the 'ended' state). * This matches the iOS SDK `getCall(callId:)` method. * * @param callId The unique identifier of a call. * @returns The Call object that matches the requested callId, or null if not found. * @example * ```typescript * const call = telnyxRTC.getCall('some-call-uuid'); * if (call) { * console.log('Call state:', call.state); * } * ``` */ public getCall(callId: string): Call | null { return this.calls.get(callId) || null; } /** * Get all active calls as an array. * @returns Array of all tracked Call objects */ public getActiveCalls(): Call[] { return Array.from(this.calls.values()).filter((call) => call.state !== 'ended'); } /** * Check if there are any active calls (not in 'ended' state). * Matches iOS SDK `isCallsActive` property. */ public get hasActiveCalls(): boolean { for (const call of this.calls.values()) { if (call.state !== 'ended') { return true; } } return false; } /** * Add a call to the calls dictionary and set up state change listener. * @internal */ private addCall(call: Call): void { const callId = call.callId; log.debug(`[TelnyxRTC] Adding call to tracking: ${callId}`); this.calls.set(callId, call); const stateListener = (updatedCall: Call, state: string) => { log.debug(`[TelnyxRTC] Call ${callId} state changed to: ${state}`); this.emit('telnyx.call.stateChanged', updatedCall, state); if (state === 'ended') { log.debug(`[TelnyxRTC] Call ${callId} ended, removing from tracking`); this.removeCall(callId); } }; this.callStateListeners.set(callId, stateListener); call.on('telnyx.call.state', stateListener); this.drainPendingTrickleEvents(callId); log.debug(`[TelnyxRTC] Total calls tracked: ${this.calls.size}`); } private updateTrackedCallId(previousCallId: string, call: Call): void { if (previousCallId === call.callId) { this.drainPendingTrickleEvents(call.callId); return; } const previousListener = this.callStateListeners.get(previousCallId); if (previousListener) { call.off('telnyx.call.state', previousListener); this.callStateListeners.delete(previousCallId); } this.calls.delete(previousCallId); this.addCall(call); } private prunePendingTrickleEvents(): void { const expiresBefore = Date.now() - TelnyxRTC.PENDING_TRICKLE_TTL_MS; for (const [callId, events] of this.pendingTrickleEvents.entries()) { const freshEvents = events.filter((event) => event.receivedAt >= expiresBefore); if (freshEvents.length > 0) { this.pendingTrickleEvents.set(callId, freshEvents); } else { this.pendingTrickleEvents.delete(callId); } } } private queuePendingTrickleEvent( callId: string, event: Omit<PendingTrickleEvent, 'receivedAt'> ): void { this.prunePendingTrickleEvents(); const pending = this.pendingTrickleEvents.get(callId) || []; pending.push({ ...event, receivedAt: Date.now() }); this.pendingTrickleEvents.set(callId, pending); } private drainPendingTrickleEvents(callId: string): void { const targetCall = this.getCall(callId); if (!targetCall) { return; } const pending = this.pendingTrickleEvents.get(callId); if (!pending?.length) { return; } this.pendingTrickleEvents.delete(callId); const expiresBefore = Date.now() - TelnyxRTC.PENDING_TRICKLE_TTL_MS; for (const event of pending) { if (event.receivedAt < expiresBefore) { continue; } if (event.type === 'candidate') { targetCall.handleRemoteCandidate({ candidate: event.msg.params.candidate, sdpMid: event.msg.params.sdpMid, sdpMLineIndex: event.msg.params.sdpMLineIndex, }); } else { targetCall.handleRemoteEndOfCandidates(); } } } /** * Remove a call from the calls dictionary and clean up event listeners. * @internal */ private removeCall(callId: string): void { if (this.calls.has(callId)) { const call = this.calls.get(callId); const listener = this.callStateListeners.get(callId); if (call && listener) { call.off('telnyx.call.state', listener); this.callStateListeners.delete(callId); log.debug(`[TelnyxRTC] Removed state listener for call: ${callId}`); } log.debug(`[TelnyxRTC] Removing call from tracking: ${callId}`); this.calls.delete(callId); this.emit('telnyx.call.removed', callId); log.debug(`[TelnyxRTC] Total calls tracked: ${this.calls.size}`); } } // ============================================================================ // MARK: - Call Creation // ============================================================================ /** * Initiates a new call. * @param options The options for the new call. * @example * ```typescript * import { TelnyxRTC } from '@telnyx/react-native-voice-sdk'; * const telnyxRTC = new TelnyxRTC({ loginToken: 'your_login_token' }); * await telnyxRTC.connect(); * const call = await telnyxRTC.newCall({ * destinationNumber: '+1234567890', * callerIdNumber: '+0987654321', * callerIdName: 'My Name', * customHeaders: [{ name: 'X-Custom-Header', value: 'value' }], * }); * console.log('Call initiated:', call); * * @returns a Promise that resolves to the Call instance. * @throws Error if no connection or session ID exists. */ public newCall = async (options: CallOptions) => { if (!this.connection) { log.error('[TelnyxRTC] No connection exists. Please connect first.'); throw new Error('[TelnyxRTC] No connection exists. Please connect first.'); } if (!this.sessionId) { log.error('[TelnyxRTC] No session ID exists. Please connect first.'); throw new Error('[TelnyxRTC] No session ID exists. Please connect first.'); } const newCall = new Call({ connection: this.connection, direction: 'outbound', sessionId: this.sessionId, telnyxSessionId: null, telnyxLegId: null, callId: null, options, debug: this.options.debug, callReportConfig: this.getCallReportConfig(), }); // Add to calls tracking (matches iOS SDK behavior) this.addCall(newCall); const provisionalCallId = newCall.callId; await newCall.invite(); this.updateTrackedCallId(provisionalCallId, newCall); return newCall; }; /** * Process a VoIP push notification for an incoming call. * This should be called when a push notification is received to prepare for the incoming call. * @param pushNotificationPayload The push notification payload containing call metadata * @example * ```typescript * const telnyxRTC = new TelnyxRTC({ loginToken: 'your_login_token' }); * telnyxRTC.processVoIPNotification(pushPayload); * await telnyxRTC.connect(); * ``` */ public processVoIPNotification(pushNotificationPayload: any) { log.debug('[TelnyxRTC] Processing VoIP push notification:', pushNotificationPayload); this.isCallFromPush = true; this.pushNotificationPayload = pushNotificationPayload; // Extract voice_sdk_id from push notification metadata (matching iOS SDK) const metadata = pushNotificationPayload?.metadata || pushNotificationPayload; if (metadata?.voice_sdk_id) { const newVoiceSDKId = metadata.voice_sdk_id; const currentVoiceSDKId = (this as any)._pushVoiceSDKId; log.debug('[TelnyxRTC] Extracted voice_sdk_id from push notification:', newVoiceSDKId); log.debug('[TelnyxRTC] Current stored voice_sdk_id:', currentVoiceSDKId); // Console logs for release debugging console.log( '[TelnyxRTC] RELEASE DEBUG - Extracted voice_sdk_id from push notification:', newVoiceSDKId ); console.log('[TelnyxRTC] RELEASE DEBUG - Current stored voice_sdk_id:', currentVoiceSDKId); if (newVoiceSDKId !== currentVoiceSDKId) { log.debug('[TelnyxRTC] Updating voice_sdk_id with fresh value from push notification'); (this as any)._pushVoiceSDKId = newVoiceSDKId; } else { log.debug( '[TelnyxRTC] Voice_sdk_id unchanged - may be reprocessing same push notification' ); } } else { log.warn('[TelnyxRTC] No voice_sdk_id found in push notification payload'); } log.debug('[TelnyxRTC] isCallFromPush flag set to:', this.isCallFromPush); // Save push state to AsyncStorage for persistence across client recreation this.savePushState().catch((error) => { log.error('[TelnyxRTC] Failed to save push state:', error); }); } /** * Queue an answer action for when the call invite arrives (matching iOS SDK behavior) * This should be called when the user answers from CallKit before the socket connection is established * @param customHeaders Optional custom headers to include with the answer */ public queueAnswerFromCallKit(customHeaders: Record<string, string> = {}) { log.debug('[TelnyxRTC] Queuing answer action from CallKit', customHeaders); this.pendingAnswerAction = true; this.pendingCustomHeaders = customHeaders; // If call already exists, answer immediately if (this.call && this.call.state === 'ringing') { log.debug('[TelnyxRTC] Call exists, answering immediately'); this.executePendingAnswer(); } else { log.debug('[TelnyxRTC] Call not yet available, answer will be executed when invite arrives'); } } /** * Queue an end action for when the call invite arrives (matching iOS SDK behavior) * This should be called when the user ends from CallKit before the socket connection is established */ public queueEndFromCallKit() { log.debug('[TelnyxRTC] Queuing end action from CallKit'); this.pendingEndAction = true; // If call already exists, end immediately if (this.call) { log.debug('[TelnyxRTC] Call exists, ending immediately'); this.executePendingEnd(); } else { log.debug('[TelnyxRTC] Call not yet available, end will be executed when invite arrives'); } } // Store push notification CallKit UUID for automatic linking private pushNotificationCallKitUUID: string | null = null; /** * Set the CallKit UUID for the expected push notification call (matching iOS SDK approach) * This should be called by CallKitHandler using the call_id from push notification metadata */ public setPushNotificationCallKitUUID(uuid: string | null) { log.debug('[TelnyxRTC] Storing push notification CallKit UUID for automatic assignment:', uuid); this.pushNotificationCallKitUUID = uuid; } /** * Get the stored push notification CallKit UUID */ public getPushNotificationCallKitUUID(): string | null { return this.pushNotificationCallKitUUID; } /** * Execute pending answer action */ private async executePendingAnswer() { if (!this.pendingAnswerAction || !this.call) { return; } try { log.debug('[TelnyxRTC] Executing pending answer action'); // Convert Record<string, string> to { name: string; value: string }[] format const customHeaders = Object.entries(this.pendingCustomHeaders).map(([name, value]) => ({ name, value, })); // Use the answer method with custom headers await this.call.answer(customHeaders); log.debug('[TelnyxRTC] Pending answer executed successfully'); } catch (error) { log.error('[TelnyxRTC] Failed to execute pending answer:', error); } finally { this.resetPendingActions(); } } /** * Execute pending end action */ private executePendingEnd() { if (!this.pendingEndAction || !this.call) { return; } try { log.debug('[TelnyxRTC] Executing pending end action'); this.call.hangup(); log.debug('[TelnyxRTC] Pending end executed successfully'); } catch (error) { log.error('[TelnyxRTC] Failed to execute pending end:', error); } finally { this.resetPendingActions(); } } /** * Reset pending action flags (matching iOS SDK resetPushVariables) * Also resets push flags so subsequent calls are not auto-answered. */ private resetPendingActions() { log.debug('[TelnyxRTC] Resetting pending actions'); this.pendingAnswerAction = false; this.pendingEndAction = false; this.pendingCustomHeaders = {}; // Reset push flags after the pending action has been executed // so subsequent calls are not auto-answered if (this.isCallFromPush) { log.debug('[TelnyxRTC] Resetting push flags after pending action executed'); this.isCallFromPush = false; this.pushNotificationPayload = null; this.pushNotificationCallKitUUID = null; this.clearPushState().catch((error) => { log.warn('[TelnyxRTC] Failed to clear push state after pending action:', error); }); } } /** * * @returns A Promise that resolves when the connection is established. * @throws Error if the connection already exists or login fails. * @example * ```typescript * import { TelnyxRTC } from '@telnyx/react-native-voice-sdk'; * const telnyxRTC = new TelnyxRTC({ loginToken: 'your_login_token' }); * await telnyxRTC.connect(); * console.log('Connected to Telnyx RTC'); * ``` * Connects to the Telnyx RTC service. * This method initializes the connection, logs in using the provided options, * and sets up the necessary event listeners. * It emits a 'telnyx.client.ready' event when the connection is successfully established. * If a connection already exists, it logs a warning and does not create a new connection. * If the login fails, it logs an error and throws an exception. * @see {@link ClientOptions} for the options that can be passed to the constructor. */ public async connect() { if (this.connection) { log.warn('A connection already exists. Please close it before creating a new one.'); return; } log.debug('[TelnyxRTC] Starting connection process...'); // Store login configuration for potential reconnection if ('login_token' in this.options) { this.tokenSessionConfig = this.options; log.debug('[TelnyxRTC] Stored token configuration for reconnection'); } else if ('login' in this.options && 'password' in this.options) { this.credentialSessionConfig = this.options; log.debug('[TelnyxRTC] Stored credential configuration for reconnection'); } // Ensure push state is loaded before proceeding with connection // This is critical for push notifications where state might be restored from AsyncStorage try { await this.loadPushState(); log.debug('[TelnyxRTC] Push state loading completed before connection'); } catch (error) { log.error('[TelnyxRTC] Failed to load push state before connection:', error); } // Use custom voice_sdk_id for push notifications (matching iOS SDK behavior) const pushVoiceSDKId = (this as any)._pushVoiceSDKId; if (this.isCallFromPush && pushVoiceSDKId) { log.debug( '[TelnyxRTC] Creating connection with push notification voice_sdk_id:', pushVoiceSDKId ); console.log( '[TelnyxRTC] RELEASE DEBUG - Creating connection with push notification voice_sdk_id:', pushVoiceSDKId ); this.connection = new Connection(pushVoiceSDKId); } else { log.debug('[TelnyxRTC] Creating standard connection'); console.log( '[TelnyxRTC] RELEASE DEBUG - Creating standard connection, isCallFromPush:', this.isCallFromPush, 'pushVoiceSDKId:', pushVoiceSDKId ); this.connection = new Connection(); } // Store reference to this client in the connection for CallKit UUID access this.connection._client = this; log.debug('[TelnyxRTC] Setting up connection event listeners'); this.connection.addListener('telnyx.socket.message', this.onSocketMessage); // Socket error/close are propagated as `telnyx.client.error` so consumers // (e.g. SessionManager) can move to ERROR state and decide what to do // next, but we deliberately do NOT auto-reconnect from here. A previous // implementation triggered an internal reconnect on these events, which // raced against consumer-driven rebuilds (e.g. SessionManager rebuilding // on each VoIP push) and created parallel sessions the gateway then // `punt`-ed. Mid-call network changes are still handled by the NetInfo- // driven onNetworkUnavailable path. Pre-login failures are owned by the // initial connect() promise and ignored here. this.connection.addListener('telnyx.socket.error', (error) => { log.error('[TelnyxRTC] WebSocket connection error:', error); if (this.sessionId) { try { this.emit('telnyx.client.error', error as any); } catch { /* consumers may not be subscribed */ } } }); this.connection.addListener('telnyx.socket.close', () => { log.debug('[TelnyxRTC] WebSocket connection closed'); if (this.sessionId) { try { this.emit('telnyx.client.error', new Error('WebSocket closed')); } catch { /* consumers may not be subscribed */ } } }); // Wait for WebSocket connection to be established if (!this.connection.isConnected) { log.debug('[TelnyxRTC] Waiting for WebSocket connection...'); await new Promise<void>((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('WebSocket connection timeout after 15 seconds')); }, 15000); // 15 second timeout const onOpen = () => { log.debug('[TelnyxRTC] WebSocket connection established'); clearTimeout(timeout); this.connection!.removeListener('telnyx.socket.open', onOpen); this.connection!.removeListener('telnyx.socket.error', onError); this.connection!.removeListener('telnyx.socket.close', onClose); resolve(); }; const onError = (error: Event) => { log.error('[TelnyxRTC] WebSocket connection failed:', error); clearTimeout(timeout); this.connection!.removeListener('telnyx.socket.open', onOpen); this.connection!.removeListener('telnyx.socket.error', onError); this.connection!.removeListener('telnyx.socket.close', onClose); // Clear push-specific voice_sdk_id on connection failure to allow normal reconnection if ((this as any)._pushVoiceSDKId) { log.debug( '[TelnyxRTC] Clearing push voice_sdk_id after connection failure to enable normal reconnection' ); (this as any)._pushVoiceSDKId = null; } reject(new Error(`WebSocket connection failed: ${error.type}`)); }; const onClose = () => { log.debug('[TelnyxRTC] WebSocket connection closed during connection attempt'); clearTimeout(timeout); this.connection!.removeListener('telnyx.socket.open', onOpen); this.connection!.removeListener('telnyx.socket.error', onError); this.connection!.removeListener('telnyx.socket.close', onClose); // Clear push-specific voice_sdk_id on connection close to allow normal reconnection if ((this as any)._pushVoiceSDKId) { log.debug( '[TelnyxRTC] Clearing push voice_sdk_id after connection close to enable normal reconnection' ); (this as any)._pushVoiceSDKId = null; } reject(new Error('WebSocket connection closed unexpectedly')); }; this.connection!.addListener('telnyx.socket.open', onOpen); this.connection!.addListener('telnyx.socket.error', onError); this.connection!.addListener('telnyx.socket.close', onClose); }); } this.loginHandler = new LoginHandler(this.connection); this.keepAliveHandler = new KeepAliveHandler(this.connection); this.keepAliveHandler.start(); // Set push notification flags if this is a push-initiated connection log.debug('[TelnyxRTC] Checking isCallFromPush flag:', this.isCallFromPush); if (this.isCallFromPush) { log.debug('[TelnyxRTC] Setting push notification flags for login'); this.loginHandler.setAttachCall(true); this.loginHandler.setFromPush(true); } else { log.debug('[TelnyxRTC] Not a push connection, no push flags needed'); } log.debug('[TelnyxRTC] Attempting login...'); // The attach_call parameter and sessid are now handled automatically in the LoginHandler log.debug( '[TelnyxRTC] Login will include attach_call and sessid (if available) for reliable reconnection' ); this.sessionId = await this.loginHandler.login(this.options); if (!this.sessionId) { log.error('Login failed. Please check your credentials and try again.'); throw new Error('Login failed. Please check your credentials and try again.'); } log.debug('[TelnyxRTC] Login successful, session ID:', this.sessionId); // Don't send attach call if we expect an invite to come through // The attach call is mainly for cases where the invite might be missed // Since we're connected and ready, we should receive the invite directly if (this.isCallFromPush && this.pushNotificationPayload) { log.debug( '[TelnyxRTC] Connection initiated by push notification - waiting for invite instead of sending attach call' ); log.debug('[TelnyxRTC] Push payload ready for processing:', this.pushNotificationPayload); } // Process any pending invite that arrived during login (matching iOS SDK behavior) if (this.pendingInvite) { log.debug('[TelnyxRTC] Processing pending invite received during login (matches iOS SDK)'); log.debug('[TelnyxRTC] Pending invite call ID:', this.pendingInvite.params.callID); const pendingInvite = this.pendingInvite; this.pendingInvite = null; // Clear the pending invite await this.processInvite(pendingInvite); } else { log.debug('[TelnyxRTC] No pending invites to process'); } this.emit('telnyx.client.ready'); } /** * Disconnects from the Telnyx RTC service. * This method closes the WebSocket connection and removes all event listeners. * If no connection exists, it logs a warning. * @example * ```typescript * import { TelnyxRTC } from '@telnyx/react-native-voice-sdk'; * const telnyxRTC = new TelnyxRTC({ loginToken: 'your_login_token' }); * await telnyxRTC.connect(); * // ... use the TelnyxRTC instance ... * telnyxRTC.disconnect(); * console.log('Disconnected from Telnyx RTC'); * ``` */ public disconnect(fromReconnection: boolean = false) { if (!this.connection) { log.warn('No connection exists.'); return; } this.cancelReconnectionTimer(); // Clear stored configurations this.credentialSessionConfig = null; this.tokenSessionConfig = null; this.connection.close(); this.connection = null; if (!fromReconnection) { log.debug('[TelnyxRTC] Disconnected due to reconnection process'); this.removeAllListeners(); try { this.netInfoSubscription?.(); } catch (error) { log.warn('[TelnyxRTC] NetInfo subscription cleanup failed:', error); } // Clear push-specific voice_sdk_id on disconnect to ensure clean state if ((this as any)._pushVoiceSDKId) { log.debug('[TelnyxRTC] Clearing push voice_sdk_id on disconnect'); (this as any)._pushVoiceSDKId = null; } } // Reset push flags on disconnect to ensure clean state for next connection // Preserve push-related flags during reconnection so they survive the disconnect if (!this.reconnecting) { this.isCallFromPush = false; this.pushNotificationPayload = null; this.pendingInvite = null; this.pushNotificationCallKitUUID = null; // Cancel any ongoing reconnection process this.reconnecting = false; log.debug('[TelnyxRTC] Reset push flags on disconnect'); // Clear stored push state from AsyncStorage this.clearPushState().catch((error) => { log.error('[TelnyxRTC] Failed to clear push state on disconnect:', error); }); } else { log.debug('[TelnyxRTC] Preserving push flags due to ongoing reconnection'); } log.warn('[TelnyxRTC] Disconnected from Telnyx RTC'); } public get connected() { return this.connection !== null && this.connection.isConnected; } /** * Returns true only if the socket both claims to be connected AND has seen * live traffic within `maxIdleMs`. A socket that's idle longer than that * cannot be trusted after an iOS app freeze — the kernel may have killed * the TLS session without notifying the JS layer. Callers should treat a * non-fresh connection as dead and force a reconnect. */ public isFresh(maxIdleMs: number = 30000): boolean { if (!this.connection || !this.connection.isConnected) return false; return this.connection.idleMs < maxIdleMs; } /** * Exposes the socket's idle duration for diagnostics / logging. */ public get connectionIdleMs(): number { return this.connection ? this.connection.idleMs : Infinity; } private onSocketMessage = (msg: unknown) => { log.debug('[TelnyxRTC] Processing socket message:', msg); if (isInviteEvent(msg)) { log.debug('[TelnyxRTC] Detected invite event, processing...'); // Console log for release debugging console.log( '[TelnyxRTC] RELEASE DEBUG - Detected invite event, processing...', JSON.stringify(msg) ); return this.handleCallInvite(msg); } if (isAttachEvent(msg)) { log.debug('[TelnyxRTC] Detected attach event, processing...'); console.log( '[TelnyxRTC] RELEASE DEBUG - Detected attach event, processing...', JSON.stringify(msg) ); return this.handleCallAttach(msg); } if (isMediaEvent(msg)) { log.debug('[TelnyxRTC] Detected media event, processing...'); console.log( '[TelnyxRTC] RELEASE DEBUG - Detected media event, processing...', JSON.stringify(msg) ); return this.handleMediaEvent(msg); } if (isAnswerEvent(msg)) { log.debug('[TelnyxRTC] Detected answer event, processing...'); console.log( '[TelnyxRTC] RELEASE DEBUG - Detected answer event, processing...', JSON.stringify(msg) ); return this.handleCallAnswer(msg); } if (isCandidateEvent(msg)) { log.debug('[TelnyxRTC] Detected ICE candidate event, processing...'); return this.handleCandidateEvent(msg); } if (isEndOfCandidatesEvent(msg)) { log.debug('[TelnyxRTC] Detected end-of-candidates event, processing...'); return this.handleEndOfCandidatesEvent(msg); } // Check if this is an invite message that's not being detected if (msg && typeof msg === 'object' && (msg as any).method === TelnyxRTCMethod.INVITE) { log.warn('[TelnyxRTC] Received invite message but isInviteEvent returned false:', msg); } return; }; private handleCallInvite = async (msg: InviteEvent) => { log.debug('[TelnyxRTC] ====== HANDLING CALL INVITE ======'); log.debug('[TelnyxRTC] Invite message:', msg); if (!this.connection) { log.error('[TelnyxRTC] No connection exists. Please connect first.'); throw new Error('[TelnyxRTC] Cannot receive calls without connection.'); } // If sessionId is not available yet (during login process), store the invite for later processing // This matches iOS SDK behavior for push notification invites received during connection if (!this.sessionId) { log.debug('[TelnyxRTC] Session ID not available yet, storing invite for later processing'); this.pendingInvite = msg; return; } log.debug('[TelnyxRTC] Session ID available, processing invite immediately'); await this.processInvite(msg); }; private processInvite = async (msg: InviteEvent) => { log.debug('[TelnyxRTC] Processing invite with session ID:', this.sessionId); // If this is a push notification call and we have a stored CallKit UUID, // ensure it's available for the createInboundCall method if (this.isCallFromPush && this.pushNotificationCallKitUUID) { log.debug( '[TelnyxRTC] Push notification call - CallKit UUID will be automatically assigned during call creation:', this.pushNotificationCallKitUUID ); } log.debug('[TelnyxRTC] Creating inbound call object'); // Check if we have SDP from invite or from a pending media event let remoteSDP = msg.params.sdp; const pendingMediaEvent = this.pendingMediaEvents.get(msg.params.callID); if (!remoteSDP && pendingMediaEvent?.params.sdp) { log.debug('[TelnyxRTC] Using SDP from pending media event for early media/ringback'); remoteSDP = pendingMediaEvent.params.sdp; } if (!remoteSDP) { log.warn( '[TelnyxRTC] No SDP available from invite or media event - call may not have early media' ); // Use empty SDP as fallback - peer connection will handle this gracefully remoteSDP = ''; } let incomingCall: Call; try { incomingCall = await Call.createInboundCall({ connection: this.connection!, remoteSDP, sessionId: this.sessionId!, callId: msg.params.callID, telnyxLegId: msg.params.telnyx_leg_id, telnyxSessionId: msg.params.telnyx_session_id, options: { destinationNumber: msg.params.caller_id_number, peerConnectionOptions: { useTrickleIce: this.options.useTrickleIce, }, }, inviteCustomHeaders: msg.params.dialogParams?.custom_headers || null, debug: this.options.debug, callReportConfig: this.getCallReportConfig(), }); } catch (error) { log.error('[TelnyxRTC] Failed to create inbound call:', error); console.error('[TelnyxRTC] RELEASE DEBUG - createInboundCall error:', error); return; } // Add to calls tracking (matches iOS SDK behavior) this.addCall(incomingCall); log.debug('[TelnyxRTC] Call state set to connecting after creation'); // Clean up the pending media event since we've processed it if (pendingMediaEvent) { this.pendingMediaEvents.delete(msg.params.callID); log.debug('[TelnyxRTC] Cleaned up pending media event for call:', msg.params.callID); } log.debug('[TelnyxRTC] Call object created, checking for pending actions'); // Set when a push action (`reject` / pendingEndAction) caused us to // hang up the incoming call before surfacing it. Used to suppress the // `telnyx.call.incoming` emission below — otherwise the consumer briefly // sees an "incoming" call that's already in `ended` state, flashing a // ringing screen for a call that's already dead. let rejectedByPushAction = false; // Check for pending actions from CallKit (matching iOS SDK behavior) if (this.isCallFromPush) { log.debug('[TelnyxRTC] This is a push notification call, checking pending actions'); // Check if this call came from a notification Answer action const fromNotification = this.pushNotificationPayload?.from_notification; const action = this.pushNotificationPayload?.action; if (fromNotification) { log.debug('[TelnyxRTC] <Call came from >notification Answer button - auto-answering'); // Auto-answer the call since user already pressed Answer button log.debug( '[TelnyxRTC] Auto-answering push notification call, current state:', incomingCall.state ); if (action === 'answer') { incomingCall.answer(); } else if (action === 'reject') { incomingCall.hangup(); // Push action already rejected this call — bye was sent, the call // is in 'ended' state. Don't surface it to consumers as an // incoming call; otherwise the UI flashes a ringing screen for a // call that's already dead. Socket teardown for "user kills the // app afterwards" is handled by the consumer's app-backgrounded // path (mirroring the Android SDK's MainActivity.onStop disconnect // pattern), not here. rejectedByPushAction = true; } // Reset push flags immediately since the action was handled inline this.resetPendingActions(); } else if (this.pendingAnswerAction) { log.debug('[TelnyxRTC] Found pending answer action, executing...'); // Execute pending answer asynchronously to allow call setup to complete first // Push flags are reset inside resetPendingActions() after execution setTimeout(() => this.executePendingAnswer(), 100); } else if (this.pendingEndAction) { log.debug('[TelnyxRTC] Found pending end action, executing...'); // Execute pending end asynchronously // Push flags are reset inside resetPendingActions() after execution setTimeout(() => this.executePendingEnd(), 100); // Same reasoning as the inline `action === 'reject'` branch above — // we've decided to end this call before the user sees it. rejectedByPushAction = true; } else { log.debug('[TelnyxRTC] No pending actions to execute for push notification call'); // No pending actions yet - keep isCallFromPush alive so that // queueAnswerFromCallKit() can still trigger auto-answer when // the user answers from CallKit after the invite has arrived. } } else { log.debug('[TelnyxRTC] Not a push notification call, no pending actions to check'); } if (rejectedByPushAction) { log.debug('[TelnyxRTC] Suppressing telnyx.call.incoming for call rejected via push action'); return; } log.debug('[TelnyxRTC] Emitting telnyx.call.incoming event'); this.emit('telnyx.call.incoming', incomingCall, msg); log.debug('[TelnyxRTC] Emitting legacy event bus notification'); eventBus.emit('telnyx.notification', { type: 'callUpdate', call: incomingCall, }); log.debug('[TelnyxRTC] ====== CALL INVITE HANDLING COMPLETE ======'); }; private handleCallAttach = async (msg: AttachEvent) => { log.debug('[TelnyxRTC] ====== HANDLING CALL ATTACH ======'); log.debug('[TelnyxRTC] Attach message:', msg); if (!this.connection) { log.error('[TelnyxRTC] No connection exists for attach.'); return; } if (!this.sessionId) { log.error('[TelnyxRTC] No session ID available for attach.'); return; } // Reset reconnecting state (like Android SDK onAttachReceived) this.reconnecting = false; this.cancelReconnectionTimer(); log.debug('[TelnyxRTC] Creating new call object from attach message'); // Create new inbound call from attach message (like Android SDK) const attachedCall = await Call.createInboundCall({ connection: this.connection!, remoteSDP: msg.params.sdp, sessionId: this.sessionId!, callId: msg.params.callID, telnyxLegId: msg.params.telnyx_leg_id, telnyxSessionId: msg.params.telnyx_session_id, options: { destinationNumber: msg.params.caller_id_number, peerConnectionOptions: { useTrickleIce: this.options.useTrickleIce, }, }, inviteCustomHeaders: msg.params.dialogParams?.custom_headers || null, initialState: 'connecting', // Set initial state to connecting debug: this.options.debug, callReportConfig: this.getCallReportConfig(), }); // Add to calls tracking (matches iOS SDK behavior) this.addCall(attachedCall); await attachedCall .answerAttach() .then(() => { log.debug('[TelnyxRTC] AnswerAttach completed successfully'); }) .catch((error: Error) => { log.error('[TelnyxRTC] Failed to execute answerAttach:', error); }); log.debug('[TelnyxRTC] Emitting telnyx.call.reattached event for reattached call'); log.debug( '[TelnyxRTC] Listeners for telnyx.call.reattached:', this.listenerCount('telnyx.call.reattached') ); this.emit('telnyx.call.reattached', attachedCall, msg); log.debug('[TelnyxRTC] Emitting legacy event bus notification for reattached call'); eventBus.emit('telnyx.notification', { type: 'callUpdate', call: attachedCall, }); log.debug('[TelnyxRTC] ====== CALL ATTACH HANDLING COMPLETE ======'); }; private handleMediaEvent = (msg: MediaEvent) => { log.debug('[TelnyxRTC] ====== HANDLING MEDIA EVENT ======'); log.debug('[TelnyxRTC] Media message:', msg); const callID = msg.params.callID; const sdp = msg.params.sdp; const audio = msg.params.audio; const video = msg.params.video; const target = msg.params.target || 'remote'; log.debug(`[TelnyxRTC] Media event for call ${callID}:`, { sdp: sdp ? 'present' : 'not present', audio, video, target, }); // Store media event for later use (in case invite comes after) this.pendingMediaEvents.set(callID, msg); // Look up the call by ID from our calls dictionary const targetCall = this.getCall(callID); if (targetCall) { log.debug('[TelnyxRTC] Found call in tracking, applying media changes'); // If media event has SDP, handle early media setup if (sdp) { log.debug('[TelnyxRTC] Media event contains SDP - setting up early media/ringback'); targetCall.handleEarlyMedia(sdp); } // Apply audio/video changes if specified if (audio !== undefined || video !== undefined) { targetCall.handleMediaUpdate({ audio, video, target, }); } } else { log.debug('[TelnyxRTC] No call found for callID, storing media event for later processing'); // Media event received before invite - this is valid and expected for early media } // Always emit the media event for applications to handle log.debug('[TelnyxRTC] Emitting telnyx.media.received event'); this.emit('telnyx.media.received', msg); log.debug('[TelnyxRTC] ====== MEDIA EVENT HANDLING COMPLETE ======'); }; private handleCallAnswer = (msg: AnswerEvent) => { log.debug('[TelnyxRTC] ====== HANDLING CALL ANSWER ======'); log.debug('[TelnyxRTC] Answer message:', msg); const callID = msg.params.callID; const sdp = msg.params.sdp; log.debug(`[TelnyxRTC] Answer event for call ${callID}:`, { sdp: sdp ? 'present' : 'not present', }); // Look up the call by ID from our calls dictionary const targetCall = this.getCall(callID); if (targetCall) { log.debug('[TelnyxRTC] Found call in tracking, processing answer'); // Store custom headers from the ANSWER message targetCall.answerCustomHeaders = msg.params.dialogParams?.custom_headers || null; // Extract Telnyx IDs from answer event params (for outbound flows) if (msg.params.telnyx_call_control_id) { targetCall.telnyxCallControlId = msg.params.telnyx_call_control_id; } if (msg.params.telnyx_leg_id) { targetCall.telnyxLegId = msg.params.telnyx_leg_id; } if (msg.params.telnyx_session_id) { targetCall.telnyxSessionId = msg.params.telnyx_session_id; } // If answer event has SDP, set it as remote description if (sdp) { log.debug('[TelnyxRTC] Answer event contains SDP - setting as remote description'); targetCall.handleRemoteAnswer(sdp); } else { log.debug('[TelnyxRTC] Answer event has no SDP - assuming SDP was handled via media event'); } // Transition call to active state log.debug('[TelnyxRTC] Setting call state to active'); targetCall.setActive(); // Emit the answer event for applications to handle log.debug('[TelnyxRTC] Emitting telnyx.call.answered event'); this.emit('telnyx.call.answered', targetCall, msg); } else { log.warn('[TelnyxRTC] No call found for callID in answer event'); } if (msg.id != null) { this.connection.send({ id: msg.id, jsonrpc: '2.0', result: { method: 'telnyx_rtc.answer' }, }); } log.debug('[TelnyxRTC] ====== CALL ANSWER HANDLING COMPLETE ======'); }; private handleCandidateEvent = (msg: CandidateEvent) => { const callID = getCallIdFromTrickleParams(msg.params); if (!callID) { log.warn('[TelnyxRTC] Candidate event missing call ID'); return