@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.
- package/CHANGELOG.md +23 -0
- package/README.md +28 -7
- package/android/src/main/java/com/telnyx/react_voice_commons/CallForegroundService.kt +16 -2
- package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
- package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +19 -0
- package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +19 -0
- package/ios/CallKitBridge.m +20 -1
- package/ios/CallKitBridge.swift +527 -78
- package/ios/README.md +2 -2
- package/ios/VoicePnBridge.m +6 -0
- package/ios/VoicePnBridge.swift +15 -0
- package/lib/callkit/callkit-coordinator.d.ts +39 -3
- package/lib/callkit/callkit-coordinator.js +561 -148
- package/lib/callkit/callkit.d.ts +7 -0
- package/lib/callkit/callkit.js +83 -11
- package/lib/hooks/useAppStateHandler.js +6 -2
- package/lib/internal/CallKitHandler.d.ts +3 -0
- package/lib/internal/CallKitHandler.js +52 -46
- package/lib/internal/calls/call-state-controller.d.ts +33 -1
- package/lib/internal/calls/call-state-controller.js +100 -28
- package/lib/internal/session/session-manager.d.ts +15 -1
- package/lib/internal/session/session-manager.js +214 -62
- package/lib/internal/voice-pn-bridge.d.ts +18 -0
- package/lib/internal/voice-pn-bridge.js +39 -0
- package/lib/models/call.js +35 -0
- package/lib/models/config.d.ts +10 -0
- package/lib/models/config.js +2 -0
- package/lib/telnyx-voice-app.js +85 -43
- package/lib/telnyx-voip-client.d.ts +46 -5
- package/lib/telnyx-voip-client.js +238 -22
- package/package.json +6 -4
- package/src/callkit/callkit-coordinator.ts +642 -160
- package/src/callkit/callkit.ts +110 -12
- package/src/hooks/useAppStateHandler.ts +7 -2
- package/src/internal/CallKitHandler.tsx +60 -55
- package/src/internal/calls/call-state-controller.ts +112 -29
- package/src/internal/session/session-manager.ts +260 -65
- package/src/internal/voice-pn-bridge.ts +47 -0
- package/src/models/call.ts +34 -0
- package/src/models/config.ts +10 -0
- package/src/telnyx-voice-app.tsx +96 -45
- package/src/telnyx-voip-client.ts +216 -24
- 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
|
|
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
|
-
|
|
84
|
-
this.
|
|
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
|
-
|
|
118
|
-
|
|
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.
|
|
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
|
-
|
|
201
|
-
|
|
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(
|
|
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
|
-
//
|
|
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 (
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
293
|
-
|
|
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(
|
|
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
|
-
|
|
335
|
-
|
|
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.
|
|
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(
|
|
547
|
+
this.processingCalls.delete(processingKey);
|
|
370
548
|
this.cleanupCall(callKitUUID);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
console.log('CallKitCoordinator: Ignoring push
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
444
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
//
|
|
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
|
|
700
|
+
// which queues the matching auto-answer.
|
|
489
701
|
return;
|
|
490
702
|
}
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
voipClient.queueAnswerFromCallKit();
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
649
|
-
|
|
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
|
-
|
|
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
|
|
678
|
-
|
|
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
|
-
//
|
|
701
|
-
call._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
|
-
|
|
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:
|
|
719
|
-
|
|
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
|
-
|
|
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
|
/**
|