@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
text/typescript
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