@telnyx/react-voice-commons-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/TelnyxVoiceCommons.podspec +32 -0
  2. package/ios/CallKitBridge.m +44 -0
  3. package/ios/CallKitBridge.swift +879 -0
  4. package/ios/README.md +211 -0
  5. package/ios/VoicePnBridge.m +31 -0
  6. package/ios/VoicePnBridge.swift +87 -0
  7. package/lib/callkit/callkit-coordinator.d.ts +126 -0
  8. package/lib/callkit/callkit-coordinator.js +728 -0
  9. package/lib/callkit/callkit.d.ts +49 -0
  10. package/lib/callkit/callkit.js +262 -0
  11. package/lib/callkit/index.d.ts +4 -0
  12. package/lib/callkit/index.js +15 -0
  13. package/lib/callkit/use-callkit-coordinator.d.ts +21 -0
  14. package/lib/callkit/use-callkit-coordinator.js +53 -0
  15. package/lib/callkit/use-callkit.d.ts +28 -0
  16. package/lib/callkit/use-callkit.js +279 -0
  17. package/lib/context/TelnyxVoiceContext.d.ts +18 -0
  18. package/lib/context/TelnyxVoiceContext.js +18 -0
  19. package/lib/hooks/use-callkit-coordinator.d.ts +13 -0
  20. package/lib/hooks/use-callkit-coordinator.js +48 -0
  21. package/lib/hooks/useAppReadyNotifier.d.ts +9 -0
  22. package/lib/hooks/useAppReadyNotifier.js +25 -0
  23. package/lib/hooks/useAppStateHandler.d.ts +16 -0
  24. package/lib/hooks/useAppStateHandler.js +105 -0
  25. package/lib/index.d.ts +24 -0
  26. package/lib/index.js +66 -0
  27. package/lib/internal/CallKitHandler.d.ts +17 -0
  28. package/lib/internal/CallKitHandler.js +110 -0
  29. package/lib/internal/callkit-manager.d.ts +69 -0
  30. package/lib/internal/callkit-manager.js +326 -0
  31. package/lib/internal/calls/call-state-controller.d.ts +92 -0
  32. package/lib/internal/calls/call-state-controller.js +294 -0
  33. package/lib/internal/session/session-manager.d.ts +87 -0
  34. package/lib/internal/session/session-manager.js +385 -0
  35. package/lib/internal/user-defaults-helpers.d.ts +10 -0
  36. package/lib/internal/user-defaults-helpers.js +69 -0
  37. package/lib/internal/voice-pn-bridge.d.ts +14 -0
  38. package/lib/internal/voice-pn-bridge.js +5 -0
  39. package/lib/models/call-state.d.ts +61 -0
  40. package/lib/models/call-state.js +87 -0
  41. package/lib/models/call.d.ts +145 -0
  42. package/lib/models/call.js +372 -0
  43. package/lib/models/config.d.ts +64 -0
  44. package/lib/models/config.js +92 -0
  45. package/lib/models/connection-state.d.ts +34 -0
  46. package/lib/models/connection-state.js +50 -0
  47. package/lib/telnyx-voice-app.d.ts +48 -0
  48. package/lib/telnyx-voice-app.js +486 -0
  49. package/lib/telnyx-voip-client.d.ts +184 -0
  50. package/lib/telnyx-voip-client.js +386 -0
  51. package/package.json +104 -0
  52. package/src/callkit/callkit-coordinator.ts +846 -0
  53. package/src/callkit/callkit.ts +322 -0
  54. package/src/callkit/index.ts +4 -0
  55. package/src/callkit/use-callkit.ts +345 -0
  56. package/src/context/TelnyxVoiceContext.tsx +33 -0
  57. package/src/hooks/use-callkit-coordinator.ts +60 -0
  58. package/src/hooks/useAppReadyNotifier.ts +25 -0
  59. package/src/hooks/useAppStateHandler.ts +134 -0
  60. package/src/index.ts +56 -0
  61. package/src/internal/CallKitHandler.tsx +149 -0
  62. package/src/internal/callkit-manager.ts +335 -0
  63. package/src/internal/calls/call-state-controller.ts +384 -0
  64. package/src/internal/session/session-manager.ts +467 -0
  65. package/src/internal/user-defaults-helpers.ts +58 -0
  66. package/src/internal/voice-pn-bridge.ts +18 -0
  67. package/src/models/call-state.ts +98 -0
  68. package/src/models/call.ts +388 -0
  69. package/src/models/config.ts +125 -0
  70. package/src/models/connection-state.ts +50 -0
  71. package/src/telnyx-voice-app.tsx +690 -0
  72. package/src/telnyx-voip-client.ts +475 -0
  73. package/src/types/telnyx-sdk.d.ts +79 -0
@@ -0,0 +1,846 @@
1
+ import { Platform, AppState } from 'react-native';
2
+ import CallKit, { CallEndReason } from './callkit';
3
+ import { Call } from '@telnyx/react-native-voice-sdk';
4
+ import { VoicePnBridge } from '../internal/voice-pn-bridge';
5
+ import { router } from 'expo-router';
6
+ import AsyncStorage from '@react-native-async-storage/async-storage';
7
+ import { TelnyxVoipClient } from '../telnyx-voip-client';
8
+ import { TelnyxConnectionState } from '../models/connection-state';
9
+
10
+ /**
11
+ * CallKit Coordinator - Manages the proper CallKit-first flow for iOS
12
+ *
13
+ * This coordinator ensures that all call actions go through CallKit first,
14
+ * which then triggers the appropriate WebRTC actions. This follows Apple's
15
+ * guidelines for proper CallKit integration.
16
+ */
17
+ class CallKitCoordinator {
18
+ private static instance: CallKitCoordinator | null = null;
19
+
20
+ // Maps CallKit UUIDs to WebRTC calls
21
+ private callMap = new Map<string, Call>();
22
+
23
+ // Tracks calls that are being processed to prevent duplicates
24
+ private processingCalls = new Set<string>();
25
+
26
+ // Tracks calls that have already been ended in CallKit to prevent duplicate reports
27
+ private endedCalls = new Set<string>();
28
+
29
+ // Tracks calls that have already been reported as connected to prevent duplicate reports
30
+ private connectedCalls = new Set<string>();
31
+
32
+ private isCallFromPush = false;
33
+
34
+ // Reference to the VoIP client for triggering reconnection when needed
35
+ private voipClient: TelnyxVoipClient | null = null;
36
+
37
+ static getInstance(): CallKitCoordinator {
38
+ if (!CallKitCoordinator.instance) {
39
+ CallKitCoordinator.instance = new CallKitCoordinator();
40
+ }
41
+ return CallKitCoordinator.instance;
42
+ }
43
+
44
+ private constructor() {
45
+ if (Platform.OS === 'ios' && CallKit.isAvailable()) {
46
+ this.setupCallKitListeners();
47
+ }
48
+ }
49
+
50
+ private setupCallKitListeners() {
51
+ // Handle CallKit answer actions
52
+ CallKit.onAnswerCall((event) => {
53
+ this.handleCallKitAnswer(event.callUUID, event);
54
+ });
55
+
56
+ // Handle CallKit end actions
57
+ CallKit.onEndCall((event) => {
58
+ this.handleCallKitEnd(event.callUUID, event);
59
+ });
60
+
61
+ // Handle CallKit start actions (for outgoing calls)
62
+ CallKit.onStartCall((event) => {
63
+ this.handleCallKitStart(event.callUUID);
64
+ });
65
+
66
+ // Handle CallKit push received events
67
+ CallKit.onReceivePush((event) => {
68
+ this.handleCallKitPushReceived(event.callUUID, event);
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Report an incoming call to CallKit (from push notification or socket)
74
+ * For push notifications, the call is already reported - we just need to map it
75
+ */
76
+ async reportIncomingCall(
77
+ call: Call,
78
+ callerName: string,
79
+ callerNumber: string
80
+ ): Promise<string | null> {
81
+ if (Platform.OS !== 'ios' || !CallKit.isAvailable()) {
82
+ return null;
83
+ }
84
+
85
+ // This is a new call - report it to CallKit using the WebRTC call ID as CallKit UUID
86
+ const callKitUUID = call.callId;
87
+
88
+ console.log('CallKitCoordinator: Report Called called', {
89
+ callKitUUID,
90
+ webrtcCallId: call.callId,
91
+ callerName,
92
+ callerNumber,
93
+ isCallFromPush: this.isCallFromPush,
94
+ });
95
+
96
+ this.setupWebRTCCallListeners(call, callKitUUID);
97
+ this.callMap.set(callKitUUID, call);
98
+
99
+ try {
100
+ if (!this.isCallFromPush) {
101
+ console.log('CallKitCoordinator: Reporting new incoming call to CallKit', {
102
+ callKitUUID,
103
+ webrtcCallId: call.callId,
104
+ callerName,
105
+ callerNumber,
106
+ isCallFromPush: this.isCallFromPush,
107
+ });
108
+ const success = await CallKit.reportIncomingCall(callKitUUID, callerNumber, callerName);
109
+ if (success) {
110
+ return callKitUUID;
111
+ }
112
+ }
113
+
114
+ return null;
115
+ } catch (error) {
116
+ console.error('CallKitCoordinator: Failed to report incoming call', error);
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Start an outgoing call through CallKit
123
+ */
124
+ async startOutgoingCall(
125
+ call: Call,
126
+ destinationNumber: string,
127
+ displayName?: string
128
+ ): Promise<string | null> {
129
+ if (Platform.OS !== 'ios' || !CallKit.isAvailable()) {
130
+ return null;
131
+ }
132
+
133
+ const callKitUUID = call.callId;
134
+
135
+ console.log('CallKitCoordinator: Starting outgoing call through CallKit', {
136
+ callKitUUID,
137
+ webrtcCallId: call.callId,
138
+ destinationNumber,
139
+ displayName,
140
+ });
141
+
142
+ try {
143
+ const success = await CallKit.startOutgoingCall(
144
+ callKitUUID,
145
+ destinationNumber,
146
+ displayName || destinationNumber
147
+ );
148
+
149
+ if (success) {
150
+ this.callMap.set(callKitUUID, call);
151
+ this.setupWebRTCCallListeners(call, callKitUUID);
152
+ (call as any)._callKitUUID = callKitUUID;
153
+ return callKitUUID;
154
+ }
155
+
156
+ return null;
157
+ } catch (error) {
158
+ console.error('CallKitCoordinator: Failed to start outgoing call', error);
159
+ return null;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Answer a call from the app UI (CallKit-first approach)
165
+ */
166
+ async answerCallFromUI(call: Call): Promise<boolean> {
167
+ // Use comprehensive UUID lookup that checks both maps and call properties
168
+ const callKitUUID = this.getCallKitUUID(call);
169
+
170
+ console.log(
171
+ 'CallKitCoordinator: Answering call from UI using CallKit answer simulation',
172
+ callKitUUID
173
+ );
174
+
175
+ // Mark as processing to prevent duplicate actions
176
+ this.processingCalls.add(callKitUUID);
177
+
178
+ try {
179
+ // Simulate the CallKit answer action, which will trigger our answer handler
180
+ const success = await CallKit.answerCall(callKitUUID);
181
+
182
+ if (success) {
183
+ // If CallKit answer fails, fallback to direct WebRTC answer
184
+ if (this.isCallFromPush) {
185
+ call.answer();
186
+ this.isCallFromPush = false;
187
+ }
188
+ console.log('CallKitCoordinator: CallKit answer success');
189
+ }
190
+
191
+ return success;
192
+ } catch (error) {
193
+ console.error('CallKitCoordinator: Error answering call from UI', error);
194
+ return false;
195
+ } finally {
196
+ this.processingCalls.delete(callKitUUID);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * End a call from the app UI (CallKit-first approach)
202
+ */
203
+ async endCallFromUI(call: Call): Promise<boolean> {
204
+ // Use comprehensive UUID lookup that checks both maps and call properties
205
+ const callKitUUID = this.getCallKitUUID(call);
206
+ if (!callKitUUID) {
207
+ console.warn('CallKitCoordinator: Cannot end call - no CallKit UUID found');
208
+ // Fallback to direct WebRTC hangup
209
+ call.hangup();
210
+ return false;
211
+ }
212
+
213
+ console.log(
214
+ 'CallKitCoordinator: Ending call from UI - dismissing CallKit and hanging up WebRTC call',
215
+ callKitUUID
216
+ );
217
+
218
+ // Mark as processing to prevent duplicate actions
219
+ this.processingCalls.add(callKitUUID);
220
+
221
+ try {
222
+ // End the call in CallKit and hang up the WebRTC call
223
+ await CallKit.endCall(callKitUUID);
224
+ call.hangup();
225
+
226
+ // Clean up the mappings
227
+ this.cleanupCall(callKitUUID);
228
+
229
+ return true;
230
+ } catch (error) {
231
+ console.error('CallKitCoordinator: Error ending call from UI', error);
232
+ call.hangup(); // Ensure WebRTC call is ended
233
+ return false;
234
+ } finally {
235
+ this.processingCalls.delete(callKitUUID);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Handle CallKit answer action (triggered by CallKit)
241
+ */
242
+ public async handleCallKitAnswer(callKitUUID: string, event?: any) {
243
+ if (this.processingCalls.has(callKitUUID)) {
244
+ console.log('CallKitCoordinator: Answer action already being processed, skipping duplicate');
245
+ return;
246
+ }
247
+
248
+ const call = this.callMap.get(callKitUUID);
249
+
250
+ if (!call) {
251
+ console.warn('CallKitCoordinator: No WebRTC call found for CallKit answer action', {
252
+ callKitUUID,
253
+ availableCallKitUUIDs: Array.from(this.callMap.keys()),
254
+ availableWebRTCCallIds: Array.from(this.callMap.values()).map((c) => c.callId),
255
+ });
256
+
257
+ console.log('CallKitCoordinator: No WebRTC call found, handling as push notification');
258
+ await this.handlePushNotificationAnswer(callKitUUID, event);
259
+ return;
260
+ }
261
+
262
+ console.log('CallKitCoordinator: Processing CallKit answer action', {
263
+ callKitUUID,
264
+ webrtcCallId: call.callId,
265
+ direction: call.direction,
266
+ currentState: call.state,
267
+ });
268
+
269
+ if (call.state === 'active' || call.state === 'connecting') {
270
+ console.log(
271
+ 'CallKitCoordinator: Call already active/connecting, skipping duplicate answer action'
272
+ );
273
+ return;
274
+ }
275
+
276
+ this.processingCalls.add(callKitUUID);
277
+
278
+ try {
279
+ if (call.direction === 'inbound') {
280
+ const voipClient = this.getSDKClient();
281
+ if (voipClient) {
282
+ console.log(
283
+ 'CallKitCoordinator: Setting incoming call to CONNECTING state for CallKit answer'
284
+ );
285
+ voipClient.setCallConnecting(call.callId);
286
+ }
287
+
288
+ // Report call as connected to CallKit to trigger audio session activation
289
+ setTimeout(async () => {
290
+ try {
291
+ await CallKit.reportCallConnected(callKitUUID);
292
+ console.log('CallKitCoordinator: Reported call connected to activate audio session');
293
+ this.connectedCalls.add(callKitUUID);
294
+ } catch (error) {
295
+ console.error(
296
+ 'CallKitCoordinator: Error reporting call connected for audio session:',
297
+ error
298
+ );
299
+ }
300
+ }, 200);
301
+
302
+ setTimeout(() => {
303
+ call.answer();
304
+ }, 500);
305
+ } else {
306
+ console.log('CallKitCoordinator: Outgoing call, skipping answer and CONNECTING state');
307
+ }
308
+ } catch (error) {
309
+ console.error('CallKitCoordinator: Error processing CallKit answer', error);
310
+ await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
311
+ this.cleanupCall(callKitUUID);
312
+ } finally {
313
+ this.processingCalls.delete(callKitUUID);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Handle CallKit end action (triggered by CallKit)
319
+ */
320
+ private async handleCallKitEnd(callKitUUID: string, event?: any) {
321
+ this.isCallFromPush = false;
322
+
323
+ if (this.processingCalls.has(callKitUUID)) {
324
+ console.log('CallKitCoordinator: End action already being processed, skipping duplicate');
325
+ return;
326
+ }
327
+
328
+ const call = this.callMap.get(callKitUUID);
329
+
330
+ if (!call) {
331
+ console.warn('CallKitCoordinator: No WebRTC call found for CallKit end action', {
332
+ callKitUUID,
333
+ availableCallKitUUIDs: Array.from(this.callMap.keys()),
334
+ availableWebRTCCallIds: Array.from(this.callMap.values()).map((c) => c.callId),
335
+ });
336
+
337
+ console.log(
338
+ 'CallKitCoordinator: No WebRTC call found, handling as push notification rejection'
339
+ );
340
+ await this.handlePushNotificationReject(callKitUUID, event);
341
+ this.cleanupCall(callKitUUID);
342
+ return;
343
+ }
344
+
345
+ console.log('CallKitCoordinator: Processing CallKit end action', {
346
+ callKitUUID,
347
+ webrtcCallId: call.callId,
348
+ });
349
+
350
+ this.processingCalls.add(callKitUUID);
351
+
352
+ try {
353
+ call.hangup();
354
+ } catch (error) {
355
+ console.error('CallKitCoordinator: Error hanging up WebRTC call', error);
356
+ } finally {
357
+ this.processingCalls.delete(callKitUUID);
358
+ this.cleanupCall(callKitUUID);
359
+
360
+ // Check if app is in background and no more calls - disconnect client
361
+ await this.checkBackgroundDisconnection();
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Handle CallKit start action (triggered by CallKit for outgoing calls)
367
+ */
368
+ private async handleCallKitStart(callKitUUID: string) {
369
+ const call = this.callMap.get(callKitUUID);
370
+ if (!call) {
371
+ console.warn(
372
+ 'CallKitCoordinator: No WebRTC call found for CallKit start action',
373
+ callKitUUID
374
+ );
375
+ return;
376
+ }
377
+
378
+ console.log('CallKitCoordinator: Processing CallKit start action', {
379
+ callKitUUID,
380
+ webrtcCallId: call.callId,
381
+ });
382
+
383
+ // For outgoing calls, the WebRTC call should already be initiated
384
+ // We just need to report when it connects
385
+ }
386
+
387
+ /**
388
+ * Handle CallKit push received event - when a VoIP push notification has been processed
389
+ * This allows us to coordinate between the push notification and any subsequent WebRTC calls
390
+ */
391
+ async handleCallKitPushReceived(callKitUUID: string, event?: any): Promise<void> {
392
+ if (this.isCallFromPush) {
393
+ this.isCallFromPush = false;
394
+ console.log('CallKitCoordinator: Ignoring push received event (already processed)');
395
+ return;
396
+ }
397
+
398
+ console.log('CallKitCoordinator: Processing push received event', {
399
+ callKitUUID,
400
+ source: event?.callData?.source,
401
+ });
402
+
403
+ this.isCallFromPush = true;
404
+
405
+ console.log('CallKitCoordinator: Processing push received event', {
406
+ callKitUUID,
407
+ source: event?.callData?.source,
408
+ isCallFromPush: this.isCallFromPush,
409
+ });
410
+
411
+ try {
412
+ // Get VoIP client instance
413
+ const voipClient = this.getSDKClient();
414
+ if (!voipClient) {
415
+ console.error('CallKitCoordinator: VoIP client not available');
416
+ return;
417
+ }
418
+
419
+ // Retrieve pending push data from VoIP bridge
420
+ const pendingPushJson = await VoicePnBridge.getPendingVoipPush();
421
+ if (!pendingPushJson) {
422
+ console.warn('CallKitCoordinator: No pending push data found');
423
+ return;
424
+ }
425
+
426
+ const pendingPush = JSON.parse(pendingPushJson);
427
+ const realPushData = pendingPush?.payload;
428
+
429
+ if (!realPushData?.metadata) {
430
+ console.warn('CallKitCoordinator: Invalid push data structure');
431
+ return;
432
+ }
433
+
434
+ // Prepare push metadata with CallKit flag
435
+ const enhancedMetadata = {
436
+ ...realPushData.metadata,
437
+ from_callkit: true,
438
+ };
439
+
440
+ // Check if auto-answer is set and add from_notification flag
441
+ const autoAnswerFlag = await AsyncStorage.getItem('@auto_answer_next_call');
442
+ const shouldAddFromNotification = autoAnswerFlag === 'true';
443
+
444
+ let pushData;
445
+ if (shouldAddFromNotification) {
446
+ pushData = {
447
+ metadata: enhancedMetadata,
448
+ from_notification: true,
449
+ };
450
+ } else {
451
+ pushData = {
452
+ metadata: enhancedMetadata,
453
+ };
454
+ }
455
+
456
+ // Process the push notification
457
+ await voipClient.handlePushNotification(pushData);
458
+ console.log('CallKitCoordinator: Push notification processed successfully');
459
+ } catch (error) {
460
+ console.error('CallKitCoordinator: Error processing push received event:', error);
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Handle push notification answer - when user answers from CallKit but we don't have a WebRTC call yet
466
+ * This is the iOS equivalent of the Android FCM handler
467
+ */
468
+ private async handlePushNotificationAnswer(callKitUUID: string, event?: any) {
469
+ try {
470
+ console.log(
471
+ 'CallKitCoordinator: Handling push notification answer for CallKit UUID:',
472
+ callKitUUID
473
+ );
474
+
475
+ if (Platform.OS === 'ios') {
476
+ console.log('CallKitCoordinator: Processing iOS push notification answer');
477
+
478
+ // Set auto-answer flag so when the WebRTC call comes in, it will be answered automatically
479
+ await AsyncStorage.setItem('@auto_answer_next_call', 'true');
480
+ console.log('CallKitCoordinator: ✅ Set auto-answer flag for next incoming call');
481
+
482
+ // Store the CallKit UUID so we can link it when the WebRTC call arrives
483
+ await AsyncStorage.setItem('@pending_callkit_uuid', callKitUUID);
484
+ console.log('CallKitCoordinator: ✅ Stored pending CallKit UUID for linking');
485
+
486
+ // Get VoIP client and trigger reconnection
487
+ const voipClient = this.getSDKClient();
488
+ if (!voipClient) {
489
+ console.error(
490
+ 'CallKitCoordinator: ❌ No VoIP client available - cannot reconnect for push notification'
491
+ );
492
+ await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
493
+ this.cleanupCall(callKitUUID);
494
+ return;
495
+ }
496
+
497
+ // Get the real push data that was stored by the VoIP push handler
498
+ console.log('CallKitCoordinator: 🔍 Getting real push data from VoicePnBridge...');
499
+ let realPushData = null;
500
+ try {
501
+ const pendingPushJson = await VoicePnBridge.getPendingVoipPush();
502
+ if (pendingPushJson) {
503
+ const pendingPush = JSON.parse(pendingPushJson);
504
+ if (pendingPush && pendingPush.payload) {
505
+ console.log('CallKitCoordinator: ✅ Found real push data');
506
+ realPushData = pendingPush.payload;
507
+ }
508
+ }
509
+ } catch (error) {
510
+ console.warn('CallKitCoordinator: Could not get real push data:', error);
511
+ }
512
+
513
+ // Create push notification payload - use real data if available, fallback to placeholder
514
+ const pushAction = 'incoming_call';
515
+ let pushMetadata: string;
516
+
517
+ if (realPushData && realPushData.metadata) {
518
+ // Use the real push metadata
519
+ console.log('CallKitCoordinator: 🎯 Using REAL push metadata for immediate handling');
520
+ pushMetadata = JSON.stringify({
521
+ ...realPushData.metadata,
522
+ from_callkit: true, // Add flag to indicate this was answered via CallKit
523
+ });
524
+ } else {
525
+ // Fallback to placeholder (this should rarely happen)
526
+ console.warn('CallKitCoordinator: ⚠️ No real push data found, using placeholder');
527
+ pushMetadata = JSON.stringify({
528
+ call_id: callKitUUID,
529
+ caller_name: 'Incoming Call',
530
+ caller_number: 'Unknown',
531
+ voice_sdk_id: 'unknown',
532
+ sent_time: new Date().toISOString(),
533
+ from_callkit: true,
534
+ });
535
+ }
536
+
537
+ // Set the pending push action using VoicePnBridge
538
+ await VoicePnBridge.setPendingPushAction(pushAction, pushMetadata);
539
+ console.log('CallKitCoordinator: ✅ Set pending push action for reconnection');
540
+
541
+ return;
542
+ }
543
+
544
+ // For other platforms (shouldn't happen on iOS)
545
+ console.error('CallKitCoordinator: ❌ Unsupported platform for push notification handling');
546
+ await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
547
+ } catch (error) {
548
+ console.error('CallKitCoordinator: ❌ Error handling push notification answer:', error);
549
+ // Report the call as failed to CallKit
550
+ await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
551
+ this.cleanupCall(callKitUUID);
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Handle push notification reject - when user rejects from CallKit but we don't have a WebRTC call yet
557
+ * This is the iOS equivalent of the Android FCM handler reject
558
+ */
559
+ private async handlePushNotificationReject(callKitUUID: string, event?: any) {
560
+ try {
561
+ console.log(
562
+ 'CallKitCoordinator: Handling push notification rejection for CallKit UUID:',
563
+ callKitUUID
564
+ );
565
+
566
+ if (Platform.OS === 'ios') {
567
+ console.log('CallKitCoordinator: Processing iOS push notification rejection');
568
+
569
+ this.voipClient.queueEndFromCallKit();
570
+
571
+ // Clean up push notification state
572
+ await this.cleanupPushNotificationState();
573
+
574
+ console.log('CallKitCoordinator: 🎯 Push notification rejection handling complete');
575
+ return;
576
+ }
577
+
578
+ // For other platforms (shouldn't happen on iOS)
579
+ console.error(
580
+ 'CallKitCoordinator: ❌ Unsupported platform for push notification rejection handling'
581
+ );
582
+ } catch (error) {
583
+ console.error('CallKitCoordinator: ❌ Error handling push notification rejection:', error);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Set up listeners for WebRTC call state changes
589
+ */
590
+ private setupWebRTCCallListeners(call: Call, callKitUUID: string) {
591
+ const handleStateChange = async (call: Call, state: string) => {
592
+ console.log('CallKitCoordinator: WebRTC call state changed', {
593
+ callKitUUID,
594
+ webrtcCallId: call.callId,
595
+ state,
596
+ });
597
+
598
+ switch (state) {
599
+ case 'active':
600
+ // When WebRTC call becomes active, just report as connected
601
+ // (CallKit call was already answered in answerCallFromUI)
602
+ if (!this.connectedCalls.has(callKitUUID)) {
603
+ console.log('CallKitCoordinator: WebRTC call active - reporting connected to CallKit');
604
+
605
+ try {
606
+ // Report as connected (CallKit call already answered in UI flow)
607
+ await CallKit.reportCallConnected(callKitUUID);
608
+ console.log(
609
+ 'CallKitCoordinator: Call reported as connected to CallKit ',
610
+ callKitUUID
611
+ );
612
+
613
+ this.connectedCalls.add(callKitUUID);
614
+ } catch (error) {
615
+ console.error('CallKitCoordinator: Error reporting call connected:', error);
616
+ }
617
+ }
618
+ break;
619
+
620
+ case 'ended':
621
+ case 'failed':
622
+ // Report call ended to CallKit (if not already ended)
623
+ if (!this.endedCalls.has(callKitUUID)) {
624
+ console.log('CallKitCoordinator: Reporting call ended to CallKit');
625
+ const reason = state === 'failed' ? CallEndReason.Failed : CallEndReason.RemoteEnded;
626
+ await CallKit.reportCallEnded(callKitUUID, reason);
627
+ this.endedCalls.add(callKitUUID);
628
+ }
629
+
630
+ // Clean up the call mapping
631
+ this.cleanupCall(callKitUUID);
632
+ break;
633
+
634
+ case 'ringing':
635
+ // For outgoing calls, we might want to update CallKit with additional info
636
+ // For incoming calls, CallKit already knows about the call
637
+ break;
638
+ }
639
+ };
640
+
641
+ call.on('telnyx.call.state', handleStateChange);
642
+
643
+ // Store the listener cleanup function
644
+ (call as any)._callKitStateListener = () => {
645
+ call.removeListener('telnyx.call.state', handleStateChange);
646
+ };
647
+ }
648
+
649
+ /**
650
+ * Clean up call mappings and listeners
651
+ */
652
+ private cleanupCall(callKitUUID: string) {
653
+ // Remove from all tracking sets
654
+ this.processingCalls.delete(callKitUUID);
655
+ this.endedCalls.delete(callKitUUID);
656
+ this.connectedCalls.delete(callKitUUID);
657
+
658
+ // Get the call before removing it
659
+ const call = this.callMap.get(callKitUUID);
660
+
661
+ // Clean up state listeners
662
+ if (call && (call as any)._callKitStateListener) {
663
+ (call as any)._callKitStateListener();
664
+ delete (call as any)._callKitStateListener;
665
+ }
666
+
667
+ // Remove from mapping
668
+ this.callMap.delete(callKitUUID);
669
+
670
+ if (call) {
671
+ // Clean up the stored UUID on the call
672
+ delete (call as any)._callKitUUID;
673
+ }
674
+
675
+ // Reset flags if no more active calls
676
+ if (this.callMap.size === 0) {
677
+ this.resetFlags();
678
+ }
679
+
680
+ // Clear VoIP push data now that the call is done
681
+ if (Platform.OS === 'ios') {
682
+ VoicePnBridge.clearPendingVoipPush().catch((error) => {
683
+ console.warn('CallKitCoordinator: Error clearing VoIP push data on call cleanup:', error);
684
+ });
685
+ console.log('CallKitCoordinator: ✅ Cleared VoIP push data after call ended');
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Get CallKit UUID for a WebRTC call
691
+ */
692
+ getCallKitUUID(call: Call): string | null {
693
+ // First check if the call has the UUID stored on it
694
+ const storedUUID = (call as any)._callKitUUID;
695
+ if (storedUUID) {
696
+ return storedUUID;
697
+ }
698
+
699
+ // Search through all call mappings
700
+ for (const [uuid, mappedCall] of this.callMap.entries()) {
701
+ if (mappedCall.callId === call.callId) {
702
+ // Store UUID on the call for faster future lookups
703
+ (call as any)._callKitUUID = uuid;
704
+ return uuid;
705
+ }
706
+ }
707
+
708
+ return null;
709
+ }
710
+
711
+ /**
712
+ * Get WebRTC call for a CallKit UUID
713
+ */
714
+ getWebRTCCall(callKitUUID: string): Call | null {
715
+ return this.callMap.get(callKitUUID) || null;
716
+ }
717
+
718
+ /**
719
+ * Link an existing CallKit call (from push notification) with a WebRTC call
720
+ * This should be called when a WebRTC call arrives that corresponds to an existing CallKit call
721
+ */
722
+ linkExistingCallKitCall(call: Call, callKitUUID: string): void {
723
+ console.log('CallKitCoordinator: Linking existing CallKit call with WebRTC call', {
724
+ callKitUUID,
725
+ webrtcCallId: call.callId,
726
+ });
727
+
728
+ // Store the mappings
729
+ this.callMap.set(callKitUUID, call);
730
+
731
+ // Store UUID on the call for quick access
732
+ (call as any)._callKitUUID = callKitUUID;
733
+
734
+ // Set up state listeners
735
+ this.setupWebRTCCallListeners(call, callKitUUID);
736
+ }
737
+
738
+ /**
739
+ * Set the VoIP client reference for triggering reconnection
740
+ */
741
+ setVoipClient(voipClient: TelnyxVoipClient): void {
742
+ this.voipClient = voipClient;
743
+ }
744
+
745
+ /**
746
+ * Helper method to handle auto-answer logic for push notification calls
747
+ */
748
+ private async handleAutoAnswer(call: Call): Promise<void> {
749
+ const shouldAutoAnswer = await AsyncStorage.getItem('@auto_answer_next_call');
750
+ if (shouldAutoAnswer === 'true') {
751
+ console.log('CallKitCoordinator: Auto-answering call from push notification');
752
+ await AsyncStorage.removeItem('@auto_answer_next_call');
753
+
754
+ // Auto-answer the call after a brief delay to ensure CallKit is ready
755
+ setTimeout(() => {
756
+ call.answer();
757
+ }, 100);
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Helper method to clean up push notification state
763
+ */
764
+ private async cleanupPushNotificationState(): Promise<void> {
765
+ console.log('CallKitCoordinator: ✅ Cleared pending CallKit UUID and auto-answer flag');
766
+ await AsyncStorage.removeItem('@pending_callkit_uuid');
767
+ await AsyncStorage.removeItem('@auto_answer_next_call');
768
+ }
769
+
770
+ /**
771
+ * Get reference to the SDK client (for queuing actions when call doesn't exist yet)
772
+ */
773
+ private getSDKClient(): TelnyxVoipClient | null {
774
+ return this.voipClient;
775
+ }
776
+
777
+ /**
778
+ * Check if app is in background and disconnect client if no active calls
779
+ */
780
+ private async checkBackgroundDisconnection(): Promise<void> {
781
+ const currentAppState = AppState.currentState;
782
+
783
+ // Only disconnect if app is in background/inactive and no active calls
784
+ if (
785
+ (currentAppState === 'background' || currentAppState === 'inactive') &&
786
+ this.callMap.size === 0 &&
787
+ this.voipClient
788
+ ) {
789
+ console.log(
790
+ 'CallKitCoordinator: App in background with no active calls - disconnecting client'
791
+ );
792
+
793
+ try {
794
+ await this.voipClient.logout();
795
+ console.log('CallKitCoordinator: Successfully disconnected client on background');
796
+ } catch (error) {
797
+ console.error('CallKitCoordinator: Error disconnecting client on background:', error);
798
+ }
799
+ } else {
800
+ console.log('CallKitCoordinator: Skipping background disconnection', {
801
+ appState: currentAppState,
802
+ activeCalls: this.callMap.size,
803
+ hasVoipClient: !!this.voipClient,
804
+ });
805
+ }
806
+ }
807
+
808
+ /**
809
+ * Reset only flags (keeping active call mappings intact)
810
+ */
811
+ resetFlags(): void {
812
+ console.log('CallKitCoordinator: Resetting coordinator flags');
813
+
814
+ // Reset push notification flag
815
+ this.isCallFromPush = false;
816
+
817
+ console.log('CallKitCoordinator: ✅ Coordinator flags reset');
818
+ }
819
+
820
+ /**
821
+ * Check if there are any calls currently being processed by CallKit
822
+ * This helps prevent premature flag resets during CallKit operations
823
+ */
824
+ hasProcessingCalls(): boolean {
825
+ return this.processingCalls.size > 0;
826
+ }
827
+
828
+ /**
829
+ * Check if there's currently a call from push notification being processed
830
+ * This helps prevent disconnection during push call handling
831
+ */
832
+ getIsCallFromPush(): boolean {
833
+ return this.isCallFromPush;
834
+ }
835
+
836
+ /**
837
+ * Check if CallKit is available and coordinator is active
838
+ */
839
+ isAvailable(): boolean {
840
+ return Platform.OS === 'ios' && CallKit.isAvailable();
841
+ }
842
+ }
843
+
844
+ // Export singleton instance
845
+ export const callKitCoordinator = CallKitCoordinator.getInstance();
846
+ export default callKitCoordinator;