@telnyx/react-voice-commons-sdk 1.0.0 → 1.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 (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +28 -7
  3. package/android/src/main/java/com/telnyx/react_voice_commons/CallForegroundService.kt +16 -2
  4. package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
  5. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +19 -0
  6. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +19 -0
  7. package/ios/CallKitBridge.m +20 -1
  8. package/ios/CallKitBridge.swift +527 -78
  9. package/ios/README.md +2 -2
  10. package/ios/VoicePnBridge.m +6 -0
  11. package/ios/VoicePnBridge.swift +15 -0
  12. package/lib/callkit/callkit-coordinator.d.ts +39 -3
  13. package/lib/callkit/callkit-coordinator.js +561 -148
  14. package/lib/callkit/callkit.d.ts +7 -0
  15. package/lib/callkit/callkit.js +83 -11
  16. package/lib/hooks/useAppStateHandler.js +6 -2
  17. package/lib/internal/CallKitHandler.d.ts +3 -0
  18. package/lib/internal/CallKitHandler.js +52 -46
  19. package/lib/internal/calls/call-state-controller.d.ts +33 -1
  20. package/lib/internal/calls/call-state-controller.js +100 -28
  21. package/lib/internal/session/session-manager.d.ts +15 -1
  22. package/lib/internal/session/session-manager.js +214 -62
  23. package/lib/internal/voice-pn-bridge.d.ts +18 -0
  24. package/lib/internal/voice-pn-bridge.js +39 -0
  25. package/lib/models/call.js +35 -0
  26. package/lib/models/config.d.ts +10 -0
  27. package/lib/models/config.js +2 -0
  28. package/lib/telnyx-voice-app.js +85 -43
  29. package/lib/telnyx-voip-client.d.ts +46 -5
  30. package/lib/telnyx-voip-client.js +238 -22
  31. package/package.json +6 -4
  32. package/src/callkit/callkit-coordinator.ts +642 -160
  33. package/src/callkit/callkit.ts +110 -12
  34. package/src/hooks/useAppStateHandler.ts +7 -2
  35. package/src/internal/CallKitHandler.tsx +60 -55
  36. package/src/internal/calls/call-state-controller.ts +112 -29
  37. package/src/internal/session/session-manager.ts +260 -65
  38. package/src/internal/voice-pn-bridge.ts +47 -0
  39. package/src/models/call.ts +34 -0
  40. package/src/models/config.ts +10 -0
  41. package/src/telnyx-voice-app.tsx +96 -45
  42. package/src/telnyx-voip-client.ts +216 -24
  43. package/src/types/telnyx-sdk.d.ts +2 -1
@@ -73,15 +73,21 @@ class CallKitCoordinator {
73
73
  constructor() {
74
74
  // Maps CallKit UUIDs to WebRTC calls
75
75
  this.callMap = new Map();
76
- // Tracks calls that are being processed to prevent duplicates
76
+ // Tracks action+UUID pairs so an end action is not dropped merely because
77
+ // an answer or held action for the same call is still completing.
77
78
  this.processingCalls = new Set();
78
79
  // Tracks calls that have already been ended in CallKit to prevent duplicate reports
79
80
  this.endedCalls = new Set();
80
81
  // Tracks calls that have already been reported as connected to prevent duplicate reports
81
82
  this.connectedCalls = new Set();
82
83
  this.isCallFromPush = false;
83
- // Flag to auto-answer the next incoming call (set when answering push notifications via CallKit)
84
- this.shouldAutoAnswerNextCall = false;
84
+ this.pendingPushCallUUIDs = new Set();
85
+ this.selectedCallKitUUID = null;
86
+ this.restoreHeldCallPromise = null;
87
+ this.pendingSwap = null;
88
+ this.pendingHeldRequests = new Map();
89
+ // Push answers are keyed so answering B cannot auto-answer another invite.
90
+ this.autoAnswerCallUUIDs = new Set();
85
91
  // Reference to the VoIP client for triggering reconnection when needed
86
92
  this.voipClient = null;
87
93
  if (react_native_1.Platform.OS === 'ios' && callkit_1.default.isAvailable()) {
@@ -97,6 +103,9 @@ class CallKitCoordinator {
97
103
  callkit_1.default.onEndCall((event) => {
98
104
  this.handleCallKitEnd(event.callUUID, event);
99
105
  });
106
+ callkit_1.default.onHeldCall((event) => {
107
+ void this.handleCallKitHeld(event.callUUID, Boolean(event.isOnHold));
108
+ });
100
109
  // Handle CallKit start actions (for outgoing calls)
101
110
  callkit_1.default.onStartCall((event) => {
102
111
  this.handleCallKitStart(event.callUUID);
@@ -114,8 +123,13 @@ class CallKitCoordinator {
114
123
  if (react_native_1.Platform.OS !== 'ios' || !callkit_1.default.isAvailable()) {
115
124
  return null;
116
125
  }
117
- // This is a new call - report it to CallKit using the WebRTC call ID as CallKit UUID
118
- const callKitUUID = call.callId;
126
+ const existingCallKitUUID = this.getCallKitUUID(call);
127
+ if (existingCallKitUUID) {
128
+ this.linkExistingCallKitCall(call, existingCallKitUUID);
129
+ return existingCallKitUUID;
130
+ }
131
+ // Socket-only incoming calls use their signaling ID as the app-facing ID.
132
+ const callKitUUID = this.normalizeUUID(call.callId);
119
133
  console.log('CallKitCoordinator: Report Called called', {
120
134
  callKitUUID,
121
135
  webrtcCallId: call.callId,
@@ -140,12 +154,15 @@ class CallKitCoordinator {
140
154
  callerName
141
155
  );
142
156
  if (success) {
157
+ call._callKitUUID = callKitUUID;
143
158
  return callKitUUID;
144
159
  }
160
+ await this.rejectUnregisteredIncomingCall(callKitUUID, call);
145
161
  }
146
162
  return null;
147
163
  } catch (error) {
148
164
  console.error('CallKitCoordinator: Failed to report incoming call', error);
165
+ await this.rejectUnregisteredIncomingCall(callKitUUID, call);
149
166
  return null;
150
167
  }
151
168
  }
@@ -156,7 +173,7 @@ class CallKitCoordinator {
156
173
  if (react_native_1.Platform.OS !== 'ios' || !callkit_1.default.isAvailable()) {
157
174
  return null;
158
175
  }
159
- const callKitUUID = call.callId;
176
+ const callKitUUID = this.normalizeUUID(call.callId);
160
177
  console.log('CallKitCoordinator: Starting outgoing call through CallKit', {
161
178
  callKitUUID,
162
179
  webrtcCallId: call.callId,
@@ -187,30 +204,141 @@ class CallKitCoordinator {
187
204
  async answerCallFromUI(call) {
188
205
  // Use comprehensive UUID lookup that checks both maps and call properties
189
206
  const callKitUUID = this.getCallKitUUID(call);
207
+ if (!callKitUUID) {
208
+ console.warn('CallKitCoordinator: Cannot answer call - no CallKit UUID found');
209
+ return false;
210
+ }
190
211
  console.log(
191
212
  'CallKitCoordinator: Answering call from UI using CallKit answer simulation',
192
213
  callKitUUID
193
214
  );
215
+ const isRegistered = await callkit_1.default.isCallRegistered(callKitUUID);
216
+ if (!isRegistered) {
217
+ console.warn(
218
+ 'CallKitCoordinator: Suppressing answer because CallKit rejected or removed the call',
219
+ { callKitUUID }
220
+ );
221
+ await this.rejectUnregisteredIncomingCall(callKitUUID, call);
222
+ return false;
223
+ }
194
224
  // Mark as processing to prevent duplicate actions
195
- this.processingCalls.add(callKitUUID);
225
+ const processingKey = this.actionKey('answer', callKitUUID);
226
+ this.processingCalls.add(processingKey);
196
227
  try {
197
228
  // Simulate the CallKit answer action, which will trigger our answer handler
198
229
  const success = await callkit_1.default.answerCall(callKitUUID);
199
230
  if (success) {
200
- // If CallKit answer fails, fallback to direct WebRTC answer
201
- if (this.isCallFromPush) {
202
- call.answer();
203
- this.isCallFromPush = false;
231
+ if (call.direction === 'inbound' && call.state !== 'active') {
232
+ await call.answer();
204
233
  }
234
+ this.selectCall(call);
235
+ this.pendingPushCallUUIDs.delete(callKitUUID);
236
+ this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
237
+ await this.clearMatchingPendingVoipPush(callKitUUID);
205
238
  console.log('CallKitCoordinator: CallKit answer success');
206
239
  }
207
240
  return success;
208
241
  } catch (error) {
209
242
  console.error('CallKitCoordinator: Error answering call from UI', error);
243
+ await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
244
+ this.cleanupCall(callKitUUID);
210
245
  return false;
211
246
  } finally {
212
- this.processingCalls.delete(callKitUUID);
247
+ this.processingCalls.delete(processingKey);
248
+ }
249
+ }
250
+ /**
251
+ * Change held state from app UI through CallKit, then wait for signaling and
252
+ * the corresponding CXSetHeldCallAction to complete.
253
+ */
254
+ async setHeldFromUI(call, isOnHold) {
255
+ const callKitUUID = this.getCallKitUUID(call);
256
+ if (!callKitUUID) {
257
+ console.warn('CallKitCoordinator: Cannot change held state without a CallKit UUID');
258
+ return false;
259
+ }
260
+ const desiredState = isOnHold ? 'held' : 'active';
261
+ if (call.state === desiredState) {
262
+ if (!isOnHold) {
263
+ this.selectCall(call);
264
+ }
265
+ return true;
266
+ }
267
+ const requiredState = isOnHold ? 'active' : 'held';
268
+ if (call.state !== requiredState || this.pendingSwap) {
269
+ console.warn('CallKitCoordinator: Cannot change held state in current state', {
270
+ callKitUUID,
271
+ currentState: call.state,
272
+ desiredState,
273
+ hasPendingSwap: Boolean(this.pendingSwap),
274
+ });
275
+ return false;
213
276
  }
277
+ const requestKey = this.actionKey(isOnHold ? 'hold' : 'unhold', callKitUUID);
278
+ if (this.pendingHeldRequests.has(requestKey) || this.processingCalls.has(requestKey)) {
279
+ return false;
280
+ }
281
+ let resolveRequest;
282
+ const completion = new Promise((resolve) => {
283
+ resolveRequest = resolve;
284
+ });
285
+ const timeout = setTimeout(() => {
286
+ console.error('CallKitCoordinator: Held-state request timed out', {
287
+ callKitUUID,
288
+ isOnHold,
289
+ });
290
+ this.finishPendingHeldRequest(requestKey, false);
291
+ }, 12000);
292
+ this.pendingHeldRequests.set(requestKey, {
293
+ resolve: resolveRequest,
294
+ timeout,
295
+ });
296
+ const accepted = await callkit_1.default.setCallHeld(callKitUUID, isOnHold);
297
+ if (!accepted) {
298
+ this.finishPendingHeldRequest(requestKey, false);
299
+ }
300
+ return completion;
301
+ }
302
+ /**
303
+ * Swap an active call with a held call through one CallKit transaction.
304
+ * The promise settles only after both corresponding WebRTC state changes
305
+ * have completed and their CallKit actions have been fulfilled.
306
+ */
307
+ async swapCallsFromUI(activeCall, heldCall) {
308
+ const activeCallKitUUID = this.getCallKitUUID(activeCall);
309
+ const heldCallKitUUID = this.getCallKitUUID(heldCall);
310
+ if (!activeCallKitUUID || !heldCallKitUUID || activeCallKitUUID === heldCallKitUUID) {
311
+ console.warn('CallKitCoordinator: Cannot swap calls without two distinct CallKit UUIDs');
312
+ return false;
313
+ }
314
+ if (activeCall.state !== 'active' || heldCall.state !== 'held') {
315
+ console.warn('CallKitCoordinator: Cannot swap calls in their current states', {
316
+ activeCallState: activeCall.state,
317
+ heldCallState: heldCall.state,
318
+ });
319
+ return false;
320
+ }
321
+ if (this.pendingSwap) {
322
+ console.warn('CallKitCoordinator: A call swap is already in progress');
323
+ return false;
324
+ }
325
+ let resolveSwap;
326
+ const swapCompletion = new Promise((resolve) => {
327
+ resolveSwap = resolve;
328
+ });
329
+ this.pendingSwap = this.createPendingSwap({
330
+ mode: 'swap',
331
+ originalActiveCallKitUUID: activeCallKitUUID,
332
+ originalHeldCallKitUUID: heldCallKitUUID,
333
+ activeCallKitUUID,
334
+ heldCallKitUUID,
335
+ resolve: resolveSwap,
336
+ });
337
+ const accepted = await callkit_1.default.swapCalls(activeCallKitUUID, heldCallKitUUID);
338
+ if (!accepted) {
339
+ await this.beginSwapRollback(this.pendingSwap);
340
+ }
341
+ return swapCompletion;
214
342
  }
215
343
  /**
216
344
  * End a call from the app UI (CallKit-first approach)
@@ -224,29 +352,32 @@ class CallKitCoordinator {
224
352
  call.hangup();
225
353
  return false;
226
354
  }
227
- console.log(
228
- 'CallKitCoordinator: Ending call from UI - dismissing CallKit and hanging up WebRTC call',
229
- callKitUUID
230
- );
231
- // Track this call as ended to prevent duplicate end actions
232
- this.endedCalls.add(callKitUUID);
355
+ console.log('CallKitCoordinator: Requesting CallKit end action from app UI', callKitUUID);
233
356
  try {
234
- // End the call in CallKit - endCall returns false on failure (doesn't throw)
357
+ // Let the resulting CXEndCallAction drive signaling, cleanup and survivor
358
+ // restoration. Marking the call ended here would suppress that event.
235
359
  const endCallSuccess = await callkit_1.default.endCall(callKitUUID);
236
- if (!endCallSuccess) {
237
- // Fallback: use reportCallEnded to dismiss CallKit UI when endCall fails
238
- // (e.g., unknownCallUUID error from duplicate CXProvider)
239
- await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.RemoteEnded);
360
+ if (endCallSuccess) {
361
+ return true;
240
362
  }
241
- this.isCallFromPush = false;
363
+ const shouldRestoreRemainingCall = this.isSelectedCall(call);
364
+ this.endedCalls.add(callKitUUID);
365
+ await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.RemoteEnded);
366
+ this.pendingPushCallUUIDs.delete(callKitUUID);
367
+ this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
242
368
  call.hangup();
243
369
  this.cleanupCall(callKitUUID);
244
- return true;
370
+ if (shouldRestoreRemainingCall) {
371
+ await this.restoreRemainingHeldCall();
372
+ }
373
+ return false;
245
374
  } catch (error) {
246
375
  console.error('CallKitCoordinator: Error ending call from UI', error);
247
- this.isCallFromPush = false;
376
+ this.pendingPushCallUUIDs.delete(callKitUUID);
377
+ this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
248
378
  call.hangup();
249
379
  this.cleanupCall(callKitUUID);
380
+ await this.restoreRemainingHeldCall();
250
381
  return false;
251
382
  }
252
383
  }
@@ -254,7 +385,9 @@ class CallKitCoordinator {
254
385
  * Handle CallKit answer action (triggered by CallKit)
255
386
  */
256
387
  async handleCallKitAnswer(callKitUUID, event) {
257
- if (this.processingCalls.has(callKitUUID)) {
388
+ callKitUUID = this.normalizeUUID(callKitUUID);
389
+ const processingKey = this.actionKey('answer', callKitUUID);
390
+ if (this.processingCalls.has(processingKey)) {
258
391
  console.log('CallKitCoordinator: Answer action already being processed, skipping duplicate');
259
392
  return;
260
393
  }
@@ -276,10 +409,29 @@ class CallKitCoordinator {
276
409
  currentState: call.state,
277
410
  });
278
411
  if (call.state === 'active') {
279
- console.log('CallKitCoordinator: Call already active, skipping duplicate answer action');
412
+ console.log(
413
+ 'CallKitCoordinator: Call already active, completing pending CallKit answer action'
414
+ );
415
+ this.selectCall(call);
416
+ // The native CXAnswerCallAction is fulfilled only after JS reports the
417
+ // call as connected. If the WebRTC call already reached active before
418
+ // the CallKit answer event arrived, we must still report connected so
419
+ // iOS does not time out the pending answer action.
420
+ if (!this.connectedCalls.has(callKitUUID)) {
421
+ try {
422
+ await callkit_1.default.reportCallConnected(callKitUUID);
423
+ this.connectedCalls.add(callKitUUID);
424
+ console.log('CallKitCoordinator: Reported call connected for already-active call');
425
+ } catch (error) {
426
+ console.error(
427
+ 'CallKitCoordinator: Error reporting connected for already-active call:',
428
+ error
429
+ );
430
+ }
431
+ }
280
432
  return;
281
433
  }
282
- this.processingCalls.add(callKitUUID);
434
+ this.processingCalls.add(processingKey);
283
435
  try {
284
436
  if (call.direction === 'inbound') {
285
437
  const voipClient = this.getSDKClient();
@@ -289,22 +441,8 @@ class CallKitCoordinator {
289
441
  );
290
442
  voipClient.setCallConnecting(call.callId);
291
443
  }
292
- // Report call as connected to CallKit to trigger audio session activation
293
- setTimeout(async () => {
294
- try {
295
- await callkit_1.default.reportCallConnected(callKitUUID);
296
- console.log('CallKitCoordinator: Reported call connected to activate audio session');
297
- this.connectedCalls.add(callKitUUID);
298
- } catch (error) {
299
- console.error(
300
- 'CallKitCoordinator: Error reporting call connected for audio session:',
301
- error
302
- );
303
- }
304
- }, 200);
305
- setTimeout(() => {
306
- call.answer();
307
- }, 500);
444
+ await call.answer();
445
+ this.selectCall(call);
308
446
  } else {
309
447
  console.log('CallKitCoordinator: Outgoing call, skipping answer and CONNECTING state');
310
448
  }
@@ -324,15 +462,54 @@ class CallKitCoordinator {
324
462
  await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
325
463
  } catch (_) {}
326
464
  } finally {
327
- this.processingCalls.delete(callKitUUID);
465
+ this.processingCalls.delete(processingKey);
466
+ }
467
+ }
468
+ /** Handle a UUID-targeted CallKit hold or resume action. */
469
+ async handleCallKitHeld(callKitUUID, isOnHold) {
470
+ callKitUUID = this.normalizeUUID(callKitUUID);
471
+ const processingKey = this.actionKey(isOnHold ? 'hold' : 'unhold', callKitUUID);
472
+ if (this.processingCalls.has(processingKey)) {
473
+ return;
474
+ }
475
+ const call = this.callMap.get(callKitUUID);
476
+ if (!call) {
477
+ console.warn('CallKitCoordinator: No WebRTC call found for held action', { callKitUUID });
478
+ await callkit_1.default.completeHeldCallAction(callKitUUID, false);
479
+ this.finishPendingHeldRequest(processingKey, false);
480
+ return;
481
+ }
482
+ this.processingCalls.add(processingKey);
483
+ try {
484
+ if (isOnHold && call.state !== 'held') {
485
+ await call.hold();
486
+ } else if (!isOnHold && call.state !== 'active') {
487
+ await call.unhold();
488
+ }
489
+ if (!isOnHold) {
490
+ this.selectCall(call);
491
+ }
492
+ const completed = await callkit_1.default.completeHeldCallAction(callKitUUID, true);
493
+ this.recordPendingSwapAction(callKitUUID, isOnHold, completed);
494
+ this.finishPendingHeldRequest(processingKey, completed);
495
+ } catch (error) {
496
+ console.error('CallKitCoordinator: Held action failed', { callKitUUID, isOnHold, error });
497
+ await callkit_1.default.completeHeldCallAction(callKitUUID, false);
498
+ this.recordPendingSwapAction(callKitUUID, isOnHold, false);
499
+ this.finishPendingHeldRequest(processingKey, false);
500
+ } finally {
501
+ this.processingCalls.delete(processingKey);
328
502
  }
329
503
  }
330
504
  /**
331
505
  * Handle CallKit end action (triggered by CallKit)
332
506
  */
333
507
  async handleCallKitEnd(callKitUUID, event) {
334
- this.isCallFromPush = false;
335
- if (this.processingCalls.has(callKitUUID)) {
508
+ callKitUUID = this.normalizeUUID(callKitUUID);
509
+ const wasPendingPush = this.pendingPushCallUUIDs.delete(callKitUUID);
510
+ this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
511
+ const processingKey = this.actionKey('end', callKitUUID);
512
+ if (this.processingCalls.has(processingKey)) {
336
513
  console.log('CallKitCoordinator: End action already being processed, skipping duplicate');
337
514
  return;
338
515
  }
@@ -360,19 +537,23 @@ class CallKitCoordinator {
360
537
  callKitUUID,
361
538
  webrtcCallId: call.callId,
362
539
  });
363
- this.processingCalls.add(callKitUUID);
540
+ const shouldRestoreRemainingCall = this.isSelectedCall(call);
541
+ this.processingCalls.add(processingKey);
364
542
  try {
365
543
  call.hangup();
366
544
  } catch (error) {
367
545
  console.error('CallKitCoordinator: Error hanging up WebRTC call', error);
368
546
  } finally {
369
- this.processingCalls.delete(callKitUUID);
547
+ this.processingCalls.delete(processingKey);
370
548
  this.cleanupCall(callKitUUID);
371
- // Clear push data now that end action is fulfilled
372
- try {
373
- await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
374
- console.log('CallKitCoordinator: Cleared pending VoIP push after end fulfilled');
375
- } catch (_) {}
549
+ if (shouldRestoreRemainingCall) {
550
+ await this.restoreRemainingHeldCall();
551
+ }
552
+ if (wasPendingPush) {
553
+ await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush().catch((error) => {
554
+ console.warn('CallKitCoordinator: Failed to clear ended call push data', error);
555
+ });
556
+ }
376
557
  // Check if app is in background and no more calls - disconnect client
377
558
  await this.checkBackgroundDisconnection();
378
559
  }
@@ -399,18 +580,34 @@ class CallKitCoordinator {
399
580
  /**
400
581
  * Handle CallKit push received event
401
582
  * This allows us to coordinate between the push notification and any subsequent WebRTC calls
583
+ *
584
+ * Returns `true` when the push was accepted and call processing was started,
585
+ * or `false` when the push was filtered, rejected, or otherwise ignored so
586
+ * that callers can clean up any foreground-push lifecycle flags they set
587
+ * before calling this method.
402
588
  */
403
589
  async handleCallKitPushReceived(callKitUUID, event) {
404
- if (this.isCallFromPush) {
405
- this.isCallFromPush = false;
406
- console.log('CallKitCoordinator: Ignoring push received event (already processed)');
407
- return;
590
+ callKitUUID = this.normalizeUUID(callKitUUID);
591
+ if (this.pendingPushCallUUIDs.has(callKitUUID)) {
592
+ console.log('CallKitCoordinator: Ignoring duplicate push UUID', callKitUUID);
593
+ return false;
594
+ }
595
+ const isRegistered = await callkit_1.default.isCallRegistered(callKitUUID);
596
+ if (!isRegistered) {
597
+ console.warn(
598
+ 'CallKitCoordinator: Ignoring push because its CallKit registration was rejected',
599
+ { callKitUUID }
600
+ );
601
+ this.getSDKClient()?.queueEndFromCallKit(callKitUUID);
602
+ await this.clearMatchingPendingVoipPush(callKitUUID);
603
+ return false;
408
604
  }
409
605
  console.log('CallKitCoordinator: Processing push received event', {
410
606
  callKitUUID,
411
607
  source: event?.callData?.source,
412
608
  });
413
609
  this.isCallFromPush = true;
610
+ this.pendingPushCallUUIDs.add(callKitUUID);
414
611
  console.log('CallKitCoordinator: Processing push received event', {
415
612
  callKitUUID,
416
613
  source: event?.callData?.source,
@@ -420,36 +617,48 @@ class CallKitCoordinator {
420
617
  // Get VoIP client instance
421
618
  const voipClient = this.getSDKClient();
422
619
  if (!voipClient) {
423
- console.error('CallKitCoordinator: VoIP client not available');
424
- return;
620
+ throw new Error('CallKitCoordinator: VoIP client not available');
425
621
  }
426
622
  // Retrieve pending push data from VoIP bridge
427
623
  const pendingPushJson = await voice_pn_bridge_1.VoicePnBridge.getPendingVoipPush();
428
624
  if (!pendingPushJson) {
429
- console.warn('CallKitCoordinator: No pending push data found');
430
- return;
625
+ throw new Error('CallKitCoordinator: No pending push data found');
431
626
  }
432
627
  const pendingPush = JSON.parse(pendingPushJson);
433
628
  const realPushData = pendingPush?.payload;
434
629
  if (!realPushData?.metadata) {
435
- console.warn('CallKitCoordinator: Invalid push data structure');
436
- return;
630
+ throw new Error('CallKitCoordinator: Invalid push data structure');
437
631
  }
438
632
  // Prepare push metadata with CallKit flag
439
633
  const enhancedMetadata = {
440
634
  ...realPushData.metadata,
441
635
  from_callkit: true,
442
636
  };
443
- // Check if auto-answer is set and add from_notification flag
444
- const shouldAddFromNotification = this.shouldAutoAnswerNextCall;
637
+ // This app-facing UUID is attached to the next inbound Call while the
638
+ // socket INVITE retains its own signaling callID.
639
+ voipClient.setPushNotificationCallKitUUID(callKitUUID);
640
+ // Restore an answer that native CallKit received before React Native had
641
+ // attached listeners. Never apply an answer UUID to a different incoming call.
642
+ try {
643
+ const pendingAnswer = await voice_pn_bridge_1.VoicePnBridge.getPendingCallKitAnswer();
644
+ if (pendingAnswer && this.normalizeUUID(pendingAnswer) === callKitUUID) {
645
+ console.log('CallKitCoordinator: Restoring pending CallKit answer for matching UUID');
646
+ this.autoAnswerCallUUIDs.add(callKitUUID);
647
+ await voice_pn_bridge_1.VoicePnBridge.clearPendingCallKitAnswer();
648
+ }
649
+ } catch {
650
+ // The bridge method is unavailable on older native builds.
651
+ }
652
+ // A pre-INVITE CallKit answer is represented only by the UUID-keyed
653
+ // pending action. Do not also add legacy notification flags.
654
+ const shouldAddFromNotification = this.autoAnswerCallUUIDs.has(callKitUUID);
445
655
  let pushData;
446
656
  if (shouldAddFromNotification) {
447
657
  pushData = {
448
658
  metadata: enhancedMetadata,
449
- from_notification: true,
450
- action: 'answer',
451
659
  };
452
- voipClient.queueAnswerFromCallKit();
660
+ voipClient.queueAnswerFromCallKit(callKitUUID);
661
+ this.autoAnswerCallUUIDs.delete(callKitUUID);
453
662
  } else {
454
663
  pushData = {
455
664
  metadata: enhancedMetadata,
@@ -458,8 +667,12 @@ class CallKitCoordinator {
458
667
  // Process the push notification
459
668
  await voipClient.handlePushNotification(pushData);
460
669
  console.log('CallKitCoordinator: Push notification processed successfully');
670
+ return true;
461
671
  } catch (error) {
672
+ this.pendingPushCallUUIDs.delete(callKitUUID);
673
+ this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
462
674
  console.error('CallKitCoordinator: Error processing push received event:', error);
675
+ return false;
463
676
  }
464
677
  }
465
678
  /**
@@ -474,68 +687,28 @@ class CallKitCoordinator {
474
687
  );
475
688
  if (react_native_1.Platform.OS === 'ios') {
476
689
  console.log('CallKitCoordinator: Processing iOS push notification answer');
477
- // Set auto-answer flag so when the WebRTC call comes in, it will be answered automatically
478
- this.shouldAutoAnswerNextCall = true;
479
- console.log('CallKitCoordinator: ✅ Set auto-answer flag for next incoming call');
690
+ this.autoAnswerCallUUIDs.add(callKitUUID);
691
+ console.log('CallKitCoordinator: Set UUID-targeted auto-answer', callKitUUID);
480
692
  // Try to get VoIP client - it may not be wired yet if user answered
481
693
  // from CallKit before React Native finished initializing
482
694
  const voipClient = this.getSDKClient();
483
695
  if (!voipClient) {
484
696
  // voipClient not ready yet - DON'T fail the call.
485
- // shouldAutoAnswerNextCall is already set to true above.
697
+ // The UUID-targeted auto-answer is already stored above.
486
698
  // checkForInitialPushNotification() will run after setVoipClient()
487
699
  // and will find the push data still intact, call handleCallKitPushReceived()
488
- // which checks shouldAutoAnswerNextCall and queues the auto-answer.
700
+ // which queues the matching auto-answer.
489
701
  return;
490
702
  }
491
- // voipClient is available - queue the answer action on the TelnyxRTC client
492
- // so when the INVITE arrives after WebSocket login, processInvite() sees
493
- // pendingAnswerAction=true and auto-answers the call.
494
- voipClient.queueAnswerFromCallKit();
495
- // Get the real push data that was stored by the VoIP push handler
496
- let realPushData = null;
497
- try {
498
- const pendingPushJson = await voice_pn_bridge_1.VoicePnBridge.getPendingVoipPush();
499
- if (pendingPushJson) {
500
- const pendingPush = JSON.parse(pendingPushJson);
501
- if (pendingPush && pendingPush.payload) {
502
- console.log('CallKitCoordinator: ✅ Found real push data');
503
- realPushData = pendingPush.payload;
504
- }
505
- }
506
- } catch (error) {
507
- console.warn('CallKitCoordinator: Could not get real push data:', error);
508
- }
509
- // Create push notification payload - use real data if available, fallback to placeholder
510
- const pushAction = 'incoming_call';
511
- let pushMetadata;
512
- if (realPushData && realPushData.metadata) {
513
- // Use the real push metadata
514
- console.log('CallKitCoordinator: 🎯 Using REAL push metadata for immediate handling');
515
- pushMetadata = JSON.stringify({
516
- ...realPushData.metadata,
517
- from_callkit: true, // Add flag to indicate this was answered via CallKit
518
- });
519
- } else {
520
- // Fallback to placeholder (this should rarely happen)
521
- console.warn('CallKitCoordinator: ⚠️ No real push data found, using placeholder');
522
- pushMetadata = JSON.stringify({
523
- call_id: callKitUUID,
524
- caller_name: 'Incoming Call',
525
- caller_number: 'Unknown',
526
- voice_sdk_id: 'unknown',
527
- sent_time: new Date().toISOString(),
528
- from_callkit: true,
529
- });
703
+ // Queue one UUID-targeted answer. If the push is already being
704
+ // processed, its eventual INVITE will consume this action. Otherwise
705
+ // process the still-persisted VoIP payload now.
706
+ voipClient.queueAnswerFromCallKit(callKitUUID);
707
+ if (this.pendingPushCallUUIDs.has(callKitUUID)) {
708
+ this.autoAnswerCallUUIDs.delete(callKitUUID);
709
+ return;
530
710
  }
531
- // Set the pending push action to be handled when app comes to foreground
532
- await voice_pn_bridge_1.VoicePnBridge.setPendingPushAction(pushAction, pushMetadata);
533
- console.log('CallKitCoordinator: ✅ Set pending push action');
534
- // Clear push data now that push notification answer is handled
535
- try {
536
- await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
537
- console.log('CallKitCoordinator: Cleared pending VoIP push after push answer handled');
538
- } catch (_) {}
711
+ await this.handleCallKitPushReceived(callKitUUID, event);
539
712
  return;
540
713
  }
541
714
  // For other platforms (shouldn't happen on iOS)
@@ -560,7 +733,7 @@ class CallKitCoordinator {
560
733
  );
561
734
  if (react_native_1.Platform.OS === 'ios') {
562
735
  console.log('CallKitCoordinator: Processing iOS push notification rejection');
563
- this.voipClient.queueEndFromCallKit();
736
+ this.getSDKClient()?.queueEndFromCallKit(callKitUUID);
564
737
  // Clean up push notification state
565
738
  await this.cleanupPushNotificationState();
566
739
  // Clear push data now that rejection is handled
@@ -591,6 +764,10 @@ class CallKitCoordinator {
591
764
  });
592
765
  switch (state) {
593
766
  case 'active':
767
+ this.selectCall(call);
768
+ this.pendingPushCallUUIDs.delete(callKitUUID);
769
+ this.autoAnswerCallUUIDs.delete(callKitUUID);
770
+ this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
594
771
  // When WebRTC call becomes active, just report as connected
595
772
  // (CallKit call was already answered in answerCallFromUI)
596
773
  if (!this.connectedCalls.has(callKitUUID)) {
@@ -604,7 +781,10 @@ class CallKitCoordinator {
604
781
  }
605
782
  break;
606
783
  case 'ended':
607
- case 'failed':
784
+ case 'failed': {
785
+ const shouldRestoreRemainingCall =
786
+ this.selectedCallKitUUID === callKitUUID &&
787
+ !this.processingCalls.has(this.actionKey('end', callKitUUID));
608
788
  // Report call ended to CallKit (if not already ended)
609
789
  if (!this.endedCalls.has(callKitUUID)) {
610
790
  console.log('CallKitCoordinator: Reporting call ended to CallKit');
@@ -617,7 +797,11 @@ class CallKitCoordinator {
617
797
  }
618
798
  // Clean up the call mapping
619
799
  this.cleanupCall(callKitUUID);
800
+ if (shouldRestoreRemainingCall) {
801
+ await this.restoreRemainingHeldCall();
802
+ }
620
803
  break;
804
+ }
621
805
  case 'ringing':
622
806
  // For outgoing calls, we might want to update CallKit with additional info
623
807
  // For incoming calls, CallKit already knows about the call
@@ -634,8 +818,26 @@ class CallKitCoordinator {
634
818
  * Clean up call mappings and listeners
635
819
  */
636
820
  cleanupCall(callKitUUID) {
637
- this.processingCalls.delete(callKitUUID);
821
+ callKitUUID = this.normalizeUUID(callKitUUID);
822
+ if (
823
+ this.pendingSwap?.activeCallKitUUID === callKitUUID ||
824
+ this.pendingSwap?.heldCallKitUUID === callKitUUID
825
+ ) {
826
+ this.finishPendingSwap(false);
827
+ }
828
+ for (const processingKey of this.processingCalls) {
829
+ if (processingKey.endsWith(`:${callKitUUID}`)) {
830
+ this.processingCalls.delete(processingKey);
831
+ }
832
+ }
833
+ for (const requestKey of this.pendingHeldRequests.keys()) {
834
+ if (requestKey.endsWith(`:${callKitUUID}`)) {
835
+ this.finishPendingHeldRequest(requestKey, false);
836
+ }
837
+ }
638
838
  this.connectedCalls.delete(callKitUUID);
839
+ this.pendingPushCallUUIDs.delete(callKitUUID);
840
+ this.autoAnswerCallUUIDs.delete(callKitUUID);
639
841
  // Get the call before removing it
640
842
  const call = this.callMap.get(callKitUUID);
641
843
  // Clean up state listeners
@@ -645,21 +847,14 @@ class CallKitCoordinator {
645
847
  }
646
848
  // Remove from mapping
647
849
  this.callMap.delete(callKitUUID);
648
- if (call) {
649
- // Clean up the stored UUID on the call
650
- delete call._callKitUUID;
850
+ if (this.selectedCallKitUUID === callKitUUID) {
851
+ this.selectedCallKitUUID = null;
651
852
  }
652
853
  // Reset flags if no more active calls
653
854
  if (this.callMap.size === 0) {
654
855
  this.resetFlags();
655
856
  }
656
- // Clear VoIP push data now that the call is done
657
- if (react_native_1.Platform.OS === 'ios') {
658
- voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush().catch((error) => {
659
- console.warn('CallKitCoordinator: Error clearing VoIP push data on call cleanup:', error);
660
- });
661
- console.log('CallKitCoordinator: ✅ Cleared VoIP push data after call ended');
662
- }
857
+ this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
663
858
  }
664
859
  /**
665
860
  * Get CallKit UUID for a WebRTC call
@@ -668,14 +863,16 @@ class CallKitCoordinator {
668
863
  // First check if the call has the UUID stored on it
669
864
  const storedUUID = call._callKitUUID;
670
865
  if (storedUUID) {
671
- return storedUUID;
866
+ return this.normalizeUUID(storedUUID);
672
867
  }
673
868
  // Search through all call mappings
674
869
  for (const [uuid, mappedCall] of this.callMap.entries()) {
675
870
  if (mappedCall.callId === call.callId) {
676
871
  // Store UUID on the call for faster future lookups
677
- call._callKitUUID = uuid;
678
- return uuid;
872
+ if (!call._callKitUUID) {
873
+ call._callKitUUID = uuid;
874
+ }
875
+ return this.normalizeUUID(uuid);
679
876
  }
680
877
  }
681
878
  return null;
@@ -684,23 +881,28 @@ class CallKitCoordinator {
684
881
  * Get WebRTC call for a CallKit UUID
685
882
  */
686
883
  getWebRTCCall(callKitUUID) {
687
- return this.callMap.get(callKitUUID) || null;
884
+ return this.callMap.get(this.normalizeUUID(callKitUUID)) || null;
688
885
  }
689
886
  /**
690
887
  * Link an existing CallKit call (from push notification) with a WebRTC call
691
888
  * This should be called when a WebRTC call arrives that corresponds to an existing CallKit call
692
889
  */
693
890
  linkExistingCallKitCall(call, callKitUUID) {
891
+ callKitUUID = this.normalizeUUID(callKitUUID);
694
892
  console.log('CallKitCoordinator: Linking existing CallKit call with WebRTC call', {
695
893
  callKitUUID,
696
894
  webrtcCallId: call.callId,
697
895
  });
698
896
  // Store the mappings
699
897
  this.callMap.set(callKitUUID, call);
700
- // Store UUID on the call for quick access
701
- call._callKitUUID = callKitUUID;
898
+ // Push-created calls already carry a durable, non-configurable UUID.
899
+ if (!call._callKitUUID) {
900
+ call._callKitUUID = callKitUUID;
901
+ }
702
902
  // Set up state listeners
703
- this.setupWebRTCCallListeners(call, callKitUUID);
903
+ if (!call._callKitStateListener) {
904
+ this.setupWebRTCCallListeners(call, callKitUUID);
905
+ }
704
906
  }
705
907
  /**
706
908
  * Set the VoIP client reference for triggering reconnection.
@@ -715,8 +917,43 @@ class CallKitCoordinator {
715
917
  * Helper method to clean up push notification state
716
918
  */
717
919
  async cleanupPushNotificationState() {
718
- console.log('CallKitCoordinator: ✅ Cleared auto-answer flag');
719
- this.shouldAutoAnswerNextCall = false;
920
+ console.log('CallKitCoordinator: Push notification state cleaned up');
921
+ }
922
+ async rejectUnregisteredIncomingCall(callKitUUID, call) {
923
+ callKitUUID = this.normalizeUUID(callKitUUID);
924
+ console.warn('CallKitCoordinator: Cleaning up unregistered incoming call', {
925
+ callKitUUID,
926
+ webrtcCallId: call.callId,
927
+ });
928
+ this.cleanupCall(callKitUUID);
929
+ try {
930
+ await call.hangup();
931
+ } catch (error) {
932
+ console.warn('CallKitCoordinator: Failed to end unregistered signaling call', error);
933
+ }
934
+ await this.clearMatchingPendingVoipPush(callKitUUID);
935
+ }
936
+ async clearMatchingPendingVoipPush(callKitUUID) {
937
+ try {
938
+ const pendingPushJson = await voice_pn_bridge_1.VoicePnBridge.getPendingVoipPush();
939
+ if (!pendingPushJson) {
940
+ return;
941
+ }
942
+ const pendingPush = JSON.parse(pendingPushJson);
943
+ const pendingCallId =
944
+ pendingPush?.payload?.metadata?.call_id ??
945
+ pendingPush?.payload?.call_id ??
946
+ pendingPush?.metadata?.call_id ??
947
+ pendingPush?.call_id;
948
+ if (
949
+ typeof pendingCallId === 'string' &&
950
+ this.normalizeUUID(pendingCallId) === this.normalizeUUID(callKitUUID)
951
+ ) {
952
+ await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
953
+ }
954
+ } catch (error) {
955
+ console.warn('CallKitCoordinator: Failed to clear matching pending VoIP push', error);
956
+ }
720
957
  }
721
958
  /**
722
959
  * Get reference to the SDK client (for queuing actions when call doesn't exist yet)
@@ -724,6 +961,185 @@ class CallKitCoordinator {
724
961
  getSDKClient() {
725
962
  return this.voipClient;
726
963
  }
964
+ normalizeUUID(callKitUUID) {
965
+ return callKitUUID.toLowerCase();
966
+ }
967
+ actionKey(action, callKitUUID) {
968
+ return `${action}:${this.normalizeUUID(callKitUUID)}`;
969
+ }
970
+ selectCall(call) {
971
+ const callKitUUID = this.getCallKitUUID(call);
972
+ const wrapperCall = this.voipClient?.findCallByTelnyxCall(call);
973
+ if (!callKitUUID || !wrapperCall || !this.voipClient) {
974
+ return;
975
+ }
976
+ this.selectedCallKitUUID = callKitUUID;
977
+ this.voipClient.setActiveCall(wrapperCall.callId);
978
+ }
979
+ isSelectedCall(call) {
980
+ const callKitUUID = this.getCallKitUUID(call);
981
+ return callKitUUID !== null && callKitUUID === this.selectedCallKitUUID;
982
+ }
983
+ recordPendingSwapAction(callKitUUID, isOnHold, success) {
984
+ const pendingSwap = this.pendingSwap;
985
+ if (!pendingSwap || pendingSwap.mode === 'restoring') {
986
+ return;
987
+ }
988
+ const isExpectedAction =
989
+ (callKitUUID === pendingSwap.activeCallKitUUID && isOnHold) ||
990
+ (callKitUUID === pendingSwap.heldCallKitUUID && !isOnHold);
991
+ if (!isExpectedAction) {
992
+ return;
993
+ }
994
+ pendingSwap.failed || (pendingSwap.failed = !success);
995
+ pendingSwap.completedCallKitUUIDs.add(callKitUUID);
996
+ if (pendingSwap.completedCallKitUUIDs.size === 2) {
997
+ if (pendingSwap.mode === 'swap' && pendingSwap.failed) {
998
+ void this.beginSwapRollback(pendingSwap);
999
+ } else {
1000
+ this.finishPendingSwap(pendingSwap.mode === 'swap');
1001
+ }
1002
+ }
1003
+ }
1004
+ createPendingSwap({
1005
+ mode,
1006
+ originalActiveCallKitUUID,
1007
+ originalHeldCallKitUUID,
1008
+ activeCallKitUUID,
1009
+ heldCallKitUUID,
1010
+ resolve,
1011
+ }) {
1012
+ const pendingSwap = {
1013
+ mode,
1014
+ originalActiveCallKitUUID,
1015
+ originalHeldCallKitUUID,
1016
+ activeCallKitUUID,
1017
+ heldCallKitUUID,
1018
+ completedCallKitUUIDs: new Set(),
1019
+ failed: false,
1020
+ resolve,
1021
+ timeout: undefined,
1022
+ };
1023
+ pendingSwap.timeout = setTimeout(() => {
1024
+ if (this.pendingSwap !== pendingSwap) {
1025
+ return;
1026
+ }
1027
+ console.error(`CallKitCoordinator: Call ${mode} timed out`);
1028
+ if (mode === 'swap') {
1029
+ void this.beginSwapRollback(pendingSwap);
1030
+ } else {
1031
+ this.finishPendingSwap(false);
1032
+ }
1033
+ }, 12000);
1034
+ return pendingSwap;
1035
+ }
1036
+ async beginSwapRollback(pendingSwap) {
1037
+ if (!pendingSwap || this.pendingSwap !== pendingSwap || pendingSwap.mode !== 'swap') {
1038
+ return;
1039
+ }
1040
+ clearTimeout(pendingSwap.timeout);
1041
+ // Keep the operation installed while restoring WebRTC state so another
1042
+ // hold or swap cannot enter and have its pending promise overwritten.
1043
+ pendingSwap.mode = 'restoring';
1044
+ await this.restoreOriginalWebRTCStates(
1045
+ pendingSwap.originalActiveCallKitUUID,
1046
+ pendingSwap.originalHeldCallKitUUID
1047
+ );
1048
+ // A call may have ended while restoration was in flight, in which case
1049
+ // cleanup already resolved and removed this operation.
1050
+ if (this.pendingSwap !== pendingSwap) {
1051
+ return;
1052
+ }
1053
+ const rollback = this.createPendingSwap({
1054
+ mode: 'rollback',
1055
+ originalActiveCallKitUUID: pendingSwap.originalActiveCallKitUUID,
1056
+ originalHeldCallKitUUID: pendingSwap.originalHeldCallKitUUID,
1057
+ // A reverse swap requests the original held call to remain held and the
1058
+ // original active call to become active again.
1059
+ activeCallKitUUID: pendingSwap.originalHeldCallKitUUID,
1060
+ heldCallKitUUID: pendingSwap.originalActiveCallKitUUID,
1061
+ resolve: pendingSwap.resolve,
1062
+ });
1063
+ this.pendingSwap = rollback;
1064
+ const accepted = await callkit_1.default.swapCalls(
1065
+ rollback.activeCallKitUUID,
1066
+ rollback.heldCallKitUUID
1067
+ );
1068
+ if (!accepted && this.pendingSwap === rollback) {
1069
+ console.error('CallKitCoordinator: Compensating swap transaction was rejected');
1070
+ this.finishPendingSwap(false);
1071
+ }
1072
+ }
1073
+ async restoreOriginalWebRTCStates(activeCallKitUUID, heldCallKitUUID) {
1074
+ const originalActive = this.callMap.get(activeCallKitUUID);
1075
+ const originalHeld = this.callMap.get(heldCallKitUUID);
1076
+ try {
1077
+ if (originalHeld?.state === 'active') {
1078
+ await originalHeld.hold();
1079
+ }
1080
+ if (originalActive?.state === 'held') {
1081
+ await originalActive.unhold();
1082
+ }
1083
+ if (originalActive?.state === 'active') {
1084
+ this.selectCall(originalActive);
1085
+ }
1086
+ } catch (error) {
1087
+ console.error('CallKitCoordinator: Failed to restore WebRTC state before swap rollback', {
1088
+ activeCallKitUUID,
1089
+ heldCallKitUUID,
1090
+ error,
1091
+ });
1092
+ }
1093
+ }
1094
+ finishPendingSwap(success) {
1095
+ const pendingSwap = this.pendingSwap;
1096
+ if (!pendingSwap) {
1097
+ return;
1098
+ }
1099
+ this.pendingSwap = null;
1100
+ clearTimeout(pendingSwap.timeout);
1101
+ pendingSwap.resolve(success);
1102
+ }
1103
+ finishPendingHeldRequest(requestKey, success) {
1104
+ const request = this.pendingHeldRequests.get(requestKey);
1105
+ if (!request) {
1106
+ return;
1107
+ }
1108
+ this.pendingHeldRequests.delete(requestKey);
1109
+ clearTimeout(request.timeout);
1110
+ request.resolve(success);
1111
+ }
1112
+ async restoreRemainingHeldCall() {
1113
+ if (this.restoreHeldCallPromise) {
1114
+ return this.restoreHeldCallPromise;
1115
+ }
1116
+ this.restoreHeldCallPromise = (async () => {
1117
+ const heldEntry = Array.from(this.callMap.entries()).find(
1118
+ ([, call]) => call.state === 'held'
1119
+ );
1120
+ if (!heldEntry) {
1121
+ return;
1122
+ }
1123
+ const [callKitUUID, heldCall] = heldEntry;
1124
+ try {
1125
+ const restored = await this.setHeldFromUI(heldCall, false);
1126
+ if (!restored) {
1127
+ throw new Error('CallKit rejected survivor resume');
1128
+ }
1129
+ console.log('CallKitCoordinator: Restored remaining held call', callKitUUID);
1130
+ } catch (error) {
1131
+ console.error('CallKitCoordinator: Failed to restore remaining held call', {
1132
+ callKitUUID,
1133
+ error,
1134
+ });
1135
+ }
1136
+ })();
1137
+ try {
1138
+ await this.restoreHeldCallPromise;
1139
+ } finally {
1140
+ this.restoreHeldCallPromise = null;
1141
+ }
1142
+ }
727
1143
  /**
728
1144
  * Check if app is in background and disconnect client if no active calls
729
1145
  */
@@ -757,10 +1173,7 @@ class CallKitCoordinator {
757
1173
  */
758
1174
  resetFlags() {
759
1175
  console.log('CallKitCoordinator: Resetting coordinator flags');
760
- // Reset push notification flag
761
- this.isCallFromPush = false;
762
- // Reset auto-answer flag
763
- this.shouldAutoAnswerNextCall = false;
1176
+ this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
764
1177
  console.log('CallKitCoordinator: ✅ Coordinator flags reset');
765
1178
  }
766
1179
  /**