@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
|
@@ -2,9 +2,24 @@ import { Platform, AppState } from 'react-native';
|
|
|
2
2
|
import CallKit, { CallEndReason } from './callkit';
|
|
3
3
|
import { Call } from '@telnyx/react-native-voice-sdk';
|
|
4
4
|
import { VoicePnBridge } from '../internal/voice-pn-bridge';
|
|
5
|
-
import { TelnyxVoipClient } from '../telnyx-voip-client';
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import type { TelnyxVoipClient } from '../telnyx-voip-client';
|
|
6
|
+
|
|
7
|
+
type PendingSwap = {
|
|
8
|
+
mode: 'swap' | 'restoring' | 'rollback';
|
|
9
|
+
originalActiveCallKitUUID: string;
|
|
10
|
+
originalHeldCallKitUUID: string;
|
|
11
|
+
activeCallKitUUID: string;
|
|
12
|
+
heldCallKitUUID: string;
|
|
13
|
+
completedCallKitUUIDs: Set<string>;
|
|
14
|
+
failed: boolean;
|
|
15
|
+
resolve: (success: boolean) => void;
|
|
16
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type PendingHeldRequest = {
|
|
20
|
+
resolve: (success: boolean) => void;
|
|
21
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
22
|
+
};
|
|
8
23
|
|
|
9
24
|
/**
|
|
10
25
|
* CallKit Coordinator - Manages the proper CallKit-first flow for iOS
|
|
@@ -19,7 +34,8 @@ class CallKitCoordinator {
|
|
|
19
34
|
// Maps CallKit UUIDs to WebRTC calls
|
|
20
35
|
private callMap = new Map<string, Call>();
|
|
21
36
|
|
|
22
|
-
// Tracks
|
|
37
|
+
// Tracks action+UUID pairs so an end action is not dropped merely because
|
|
38
|
+
// an answer or held action for the same call is still completing.
|
|
23
39
|
private processingCalls = new Set<string>();
|
|
24
40
|
|
|
25
41
|
// Tracks calls that have already been ended in CallKit to prevent duplicate reports
|
|
@@ -29,9 +45,14 @@ class CallKitCoordinator {
|
|
|
29
45
|
private connectedCalls = new Set<string>();
|
|
30
46
|
|
|
31
47
|
private isCallFromPush = false;
|
|
48
|
+
private pendingPushCallUUIDs = new Set<string>();
|
|
49
|
+
private selectedCallKitUUID: string | null = null;
|
|
50
|
+
private restoreHeldCallPromise: Promise<void> | null = null;
|
|
51
|
+
private pendingSwap: PendingSwap | null = null;
|
|
52
|
+
private pendingHeldRequests = new Map<string, PendingHeldRequest>();
|
|
32
53
|
|
|
33
|
-
//
|
|
34
|
-
private
|
|
54
|
+
// Push answers are keyed so answering B cannot auto-answer another invite.
|
|
55
|
+
private autoAnswerCallUUIDs = new Set<string>();
|
|
35
56
|
|
|
36
57
|
// Reference to the VoIP client for triggering reconnection when needed
|
|
37
58
|
private voipClient: TelnyxVoipClient | null = null;
|
|
@@ -60,6 +81,10 @@ class CallKitCoordinator {
|
|
|
60
81
|
this.handleCallKitEnd(event.callUUID, event);
|
|
61
82
|
});
|
|
62
83
|
|
|
84
|
+
CallKit.onHeldCall((event) => {
|
|
85
|
+
void this.handleCallKitHeld(event.callUUID, Boolean(event.isOnHold));
|
|
86
|
+
});
|
|
87
|
+
|
|
63
88
|
// Handle CallKit start actions (for outgoing calls)
|
|
64
89
|
CallKit.onStartCall((event) => {
|
|
65
90
|
this.handleCallKitStart(event.callUUID);
|
|
@@ -84,8 +109,14 @@ class CallKitCoordinator {
|
|
|
84
109
|
return null;
|
|
85
110
|
}
|
|
86
111
|
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
const existingCallKitUUID = this.getCallKitUUID(call);
|
|
113
|
+
if (existingCallKitUUID) {
|
|
114
|
+
this.linkExistingCallKitCall(call, existingCallKitUUID);
|
|
115
|
+
return existingCallKitUUID;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Socket-only incoming calls use their signaling ID as the app-facing ID.
|
|
119
|
+
const callKitUUID = this.normalizeUUID(call.callId);
|
|
89
120
|
|
|
90
121
|
console.log('CallKitCoordinator: Report Called called', {
|
|
91
122
|
callKitUUID,
|
|
@@ -109,13 +140,17 @@ class CallKitCoordinator {
|
|
|
109
140
|
});
|
|
110
141
|
const success = await CallKit.reportIncomingCall(callKitUUID, callerNumber, callerName);
|
|
111
142
|
if (success) {
|
|
143
|
+
(call as any)._callKitUUID = callKitUUID;
|
|
112
144
|
return callKitUUID;
|
|
113
145
|
}
|
|
146
|
+
|
|
147
|
+
await this.rejectUnregisteredIncomingCall(callKitUUID, call);
|
|
114
148
|
}
|
|
115
149
|
|
|
116
150
|
return null;
|
|
117
151
|
} catch (error) {
|
|
118
152
|
console.error('CallKitCoordinator: Failed to report incoming call', error);
|
|
153
|
+
await this.rejectUnregisteredIncomingCall(callKitUUID, call);
|
|
119
154
|
return null;
|
|
120
155
|
}
|
|
121
156
|
}
|
|
@@ -132,7 +167,7 @@ class CallKitCoordinator {
|
|
|
132
167
|
return null;
|
|
133
168
|
}
|
|
134
169
|
|
|
135
|
-
const callKitUUID = call.callId;
|
|
170
|
+
const callKitUUID = this.normalizeUUID(call.callId);
|
|
136
171
|
|
|
137
172
|
console.log('CallKitCoordinator: Starting outgoing call through CallKit', {
|
|
138
173
|
callKitUUID,
|
|
@@ -168,35 +203,162 @@ class CallKitCoordinator {
|
|
|
168
203
|
async answerCallFromUI(call: Call): Promise<boolean> {
|
|
169
204
|
// Use comprehensive UUID lookup that checks both maps and call properties
|
|
170
205
|
const callKitUUID = this.getCallKitUUID(call);
|
|
206
|
+
if (!callKitUUID) {
|
|
207
|
+
console.warn('CallKitCoordinator: Cannot answer call - no CallKit UUID found');
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
171
210
|
|
|
172
211
|
console.log(
|
|
173
212
|
'CallKitCoordinator: Answering call from UI using CallKit answer simulation',
|
|
174
213
|
callKitUUID
|
|
175
214
|
);
|
|
176
215
|
|
|
216
|
+
const isRegistered = await CallKit.isCallRegistered(callKitUUID);
|
|
217
|
+
if (!isRegistered) {
|
|
218
|
+
console.warn(
|
|
219
|
+
'CallKitCoordinator: Suppressing answer because CallKit rejected or removed the call',
|
|
220
|
+
{ callKitUUID }
|
|
221
|
+
);
|
|
222
|
+
await this.rejectUnregisteredIncomingCall(callKitUUID, call);
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
177
226
|
// Mark as processing to prevent duplicate actions
|
|
178
|
-
this.
|
|
227
|
+
const processingKey = this.actionKey('answer', callKitUUID);
|
|
228
|
+
this.processingCalls.add(processingKey);
|
|
179
229
|
|
|
180
230
|
try {
|
|
181
231
|
// Simulate the CallKit answer action, which will trigger our answer handler
|
|
182
232
|
const success = await CallKit.answerCall(callKitUUID);
|
|
183
233
|
|
|
184
234
|
if (success) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
call.answer();
|
|
188
|
-
this.isCallFromPush = false;
|
|
235
|
+
if (call.direction === 'inbound' && call.state !== 'active') {
|
|
236
|
+
await call.answer();
|
|
189
237
|
}
|
|
238
|
+
this.selectCall(call);
|
|
239
|
+
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
240
|
+
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
241
|
+
await this.clearMatchingPendingVoipPush(callKitUUID);
|
|
190
242
|
console.log('CallKitCoordinator: CallKit answer success');
|
|
191
243
|
}
|
|
192
244
|
|
|
193
245
|
return success;
|
|
194
246
|
} catch (error) {
|
|
195
247
|
console.error('CallKitCoordinator: Error answering call from UI', error);
|
|
248
|
+
await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
|
|
249
|
+
this.cleanupCall(callKitUUID);
|
|
196
250
|
return false;
|
|
197
251
|
} finally {
|
|
198
|
-
this.processingCalls.delete(
|
|
252
|
+
this.processingCalls.delete(processingKey);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Change held state from app UI through CallKit, then wait for signaling and
|
|
258
|
+
* the corresponding CXSetHeldCallAction to complete.
|
|
259
|
+
*/
|
|
260
|
+
async setHeldFromUI(call: Call, isOnHold: boolean): Promise<boolean> {
|
|
261
|
+
const callKitUUID = this.getCallKitUUID(call);
|
|
262
|
+
if (!callKitUUID) {
|
|
263
|
+
console.warn('CallKitCoordinator: Cannot change held state without a CallKit UUID');
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const desiredState = isOnHold ? 'held' : 'active';
|
|
268
|
+
if (call.state === desiredState) {
|
|
269
|
+
if (!isOnHold) {
|
|
270
|
+
this.selectCall(call);
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const requiredState = isOnHold ? 'active' : 'held';
|
|
276
|
+
if (call.state !== requiredState || this.pendingSwap) {
|
|
277
|
+
console.warn('CallKitCoordinator: Cannot change held state in current state', {
|
|
278
|
+
callKitUUID,
|
|
279
|
+
currentState: call.state,
|
|
280
|
+
desiredState,
|
|
281
|
+
hasPendingSwap: Boolean(this.pendingSwap),
|
|
282
|
+
});
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const requestKey = this.actionKey(isOnHold ? 'hold' : 'unhold', callKitUUID);
|
|
287
|
+
if (this.pendingHeldRequests.has(requestKey) || this.processingCalls.has(requestKey)) {
|
|
288
|
+
return false;
|
|
199
289
|
}
|
|
290
|
+
|
|
291
|
+
let resolveRequest!: (success: boolean) => void;
|
|
292
|
+
const completion = new Promise<boolean>((resolve) => {
|
|
293
|
+
resolveRequest = resolve;
|
|
294
|
+
});
|
|
295
|
+
const timeout = setTimeout(() => {
|
|
296
|
+
console.error('CallKitCoordinator: Held-state request timed out', {
|
|
297
|
+
callKitUUID,
|
|
298
|
+
isOnHold,
|
|
299
|
+
});
|
|
300
|
+
this.finishPendingHeldRequest(requestKey, false);
|
|
301
|
+
}, 12_000);
|
|
302
|
+
|
|
303
|
+
this.pendingHeldRequests.set(requestKey, {
|
|
304
|
+
resolve: resolveRequest,
|
|
305
|
+
timeout,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const accepted = await CallKit.setCallHeld(callKitUUID, isOnHold);
|
|
309
|
+
if (!accepted) {
|
|
310
|
+
this.finishPendingHeldRequest(requestKey, false);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return completion;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Swap an active call with a held call through one CallKit transaction.
|
|
318
|
+
* The promise settles only after both corresponding WebRTC state changes
|
|
319
|
+
* have completed and their CallKit actions have been fulfilled.
|
|
320
|
+
*/
|
|
321
|
+
async swapCallsFromUI(activeCall: Call, heldCall: Call): Promise<boolean> {
|
|
322
|
+
const activeCallKitUUID = this.getCallKitUUID(activeCall);
|
|
323
|
+
const heldCallKitUUID = this.getCallKitUUID(heldCall);
|
|
324
|
+
|
|
325
|
+
if (!activeCallKitUUID || !heldCallKitUUID || activeCallKitUUID === heldCallKitUUID) {
|
|
326
|
+
console.warn('CallKitCoordinator: Cannot swap calls without two distinct CallKit UUIDs');
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (activeCall.state !== 'active' || heldCall.state !== 'held') {
|
|
331
|
+
console.warn('CallKitCoordinator: Cannot swap calls in their current states', {
|
|
332
|
+
activeCallState: activeCall.state,
|
|
333
|
+
heldCallState: heldCall.state,
|
|
334
|
+
});
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (this.pendingSwap) {
|
|
339
|
+
console.warn('CallKitCoordinator: A call swap is already in progress');
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let resolveSwap!: (success: boolean) => void;
|
|
344
|
+
const swapCompletion = new Promise<boolean>((resolve) => {
|
|
345
|
+
resolveSwap = resolve;
|
|
346
|
+
});
|
|
347
|
+
this.pendingSwap = this.createPendingSwap({
|
|
348
|
+
mode: 'swap',
|
|
349
|
+
originalActiveCallKitUUID: activeCallKitUUID,
|
|
350
|
+
originalHeldCallKitUUID: heldCallKitUUID,
|
|
351
|
+
activeCallKitUUID,
|
|
352
|
+
heldCallKitUUID,
|
|
353
|
+
resolve: resolveSwap,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const accepted = await CallKit.swapCalls(activeCallKitUUID, heldCallKitUUID);
|
|
357
|
+
if (!accepted) {
|
|
358
|
+
await this.beginSwapRollback(this.pendingSwap);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return swapCompletion;
|
|
200
362
|
}
|
|
201
363
|
|
|
202
364
|
/**
|
|
@@ -212,34 +374,35 @@ class CallKitCoordinator {
|
|
|
212
374
|
return false;
|
|
213
375
|
}
|
|
214
376
|
|
|
215
|
-
console.log(
|
|
216
|
-
'CallKitCoordinator: Ending call from UI - dismissing CallKit and hanging up WebRTC call',
|
|
217
|
-
callKitUUID
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
// Track this call as ended to prevent duplicate end actions
|
|
221
|
-
this.endedCalls.add(callKitUUID);
|
|
377
|
+
console.log('CallKitCoordinator: Requesting CallKit end action from app UI', callKitUUID);
|
|
222
378
|
|
|
223
379
|
try {
|
|
224
|
-
//
|
|
380
|
+
// Let the resulting CXEndCallAction drive signaling, cleanup and survivor
|
|
381
|
+
// restoration. Marking the call ended here would suppress that event.
|
|
225
382
|
const endCallSuccess = await CallKit.endCall(callKitUUID);
|
|
226
383
|
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
// (e.g., unknownCallUUID error from duplicate CXProvider)
|
|
230
|
-
await CallKit.reportCallEnded(callKitUUID, CallEndReason.RemoteEnded);
|
|
384
|
+
if (endCallSuccess) {
|
|
385
|
+
return true;
|
|
231
386
|
}
|
|
232
387
|
|
|
233
|
-
|
|
388
|
+
const shouldRestoreRemainingCall = this.isSelectedCall(call);
|
|
389
|
+
this.endedCalls.add(callKitUUID);
|
|
390
|
+
await CallKit.reportCallEnded(callKitUUID, CallEndReason.RemoteEnded);
|
|
391
|
+
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
392
|
+
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
234
393
|
call.hangup();
|
|
235
394
|
this.cleanupCall(callKitUUID);
|
|
236
|
-
|
|
237
|
-
|
|
395
|
+
if (shouldRestoreRemainingCall) {
|
|
396
|
+
await this.restoreRemainingHeldCall();
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
238
399
|
} catch (error) {
|
|
239
400
|
console.error('CallKitCoordinator: Error ending call from UI', error);
|
|
240
|
-
this.
|
|
401
|
+
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
402
|
+
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
241
403
|
call.hangup();
|
|
242
404
|
this.cleanupCall(callKitUUID);
|
|
405
|
+
await this.restoreRemainingHeldCall();
|
|
243
406
|
return false;
|
|
244
407
|
}
|
|
245
408
|
}
|
|
@@ -248,7 +411,9 @@ class CallKitCoordinator {
|
|
|
248
411
|
* Handle CallKit answer action (triggered by CallKit)
|
|
249
412
|
*/
|
|
250
413
|
public async handleCallKitAnswer(callKitUUID: string, event?: any) {
|
|
251
|
-
|
|
414
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
415
|
+
const processingKey = this.actionKey('answer', callKitUUID);
|
|
416
|
+
if (this.processingCalls.has(processingKey)) {
|
|
252
417
|
console.log('CallKitCoordinator: Answer action already being processed, skipping duplicate');
|
|
253
418
|
return;
|
|
254
419
|
}
|
|
@@ -275,11 +440,30 @@ class CallKitCoordinator {
|
|
|
275
440
|
});
|
|
276
441
|
|
|
277
442
|
if (call.state === 'active') {
|
|
278
|
-
console.log(
|
|
443
|
+
console.log(
|
|
444
|
+
'CallKitCoordinator: Call already active, completing pending CallKit answer action'
|
|
445
|
+
);
|
|
446
|
+
this.selectCall(call);
|
|
447
|
+
// The native CXAnswerCallAction is fulfilled only after JS reports the
|
|
448
|
+
// call as connected. If the WebRTC call already reached active before
|
|
449
|
+
// the CallKit answer event arrived, we must still report connected so
|
|
450
|
+
// iOS does not time out the pending answer action.
|
|
451
|
+
if (!this.connectedCalls.has(callKitUUID)) {
|
|
452
|
+
try {
|
|
453
|
+
await CallKit.reportCallConnected(callKitUUID);
|
|
454
|
+
this.connectedCalls.add(callKitUUID);
|
|
455
|
+
console.log('CallKitCoordinator: Reported call connected for already-active call');
|
|
456
|
+
} catch (error) {
|
|
457
|
+
console.error(
|
|
458
|
+
'CallKitCoordinator: Error reporting connected for already-active call:',
|
|
459
|
+
error
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
279
463
|
return;
|
|
280
464
|
}
|
|
281
465
|
|
|
282
|
-
this.processingCalls.add(
|
|
466
|
+
this.processingCalls.add(processingKey);
|
|
283
467
|
|
|
284
468
|
try {
|
|
285
469
|
if (call.direction === 'inbound') {
|
|
@@ -291,23 +475,8 @@ class CallKitCoordinator {
|
|
|
291
475
|
voipClient.setCallConnecting(call.callId);
|
|
292
476
|
}
|
|
293
477
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
try {
|
|
297
|
-
await CallKit.reportCallConnected(callKitUUID);
|
|
298
|
-
console.log('CallKitCoordinator: Reported call connected to activate audio session');
|
|
299
|
-
this.connectedCalls.add(callKitUUID);
|
|
300
|
-
} catch (error) {
|
|
301
|
-
console.error(
|
|
302
|
-
'CallKitCoordinator: Error reporting call connected for audio session:',
|
|
303
|
-
error
|
|
304
|
-
);
|
|
305
|
-
}
|
|
306
|
-
}, 200);
|
|
307
|
-
|
|
308
|
-
setTimeout(() => {
|
|
309
|
-
call.answer();
|
|
310
|
-
}, 500);
|
|
478
|
+
await call.answer();
|
|
479
|
+
this.selectCall(call);
|
|
311
480
|
} else {
|
|
312
481
|
console.log('CallKitCoordinator: Outgoing call, skipping answer and CONNECTING state');
|
|
313
482
|
}
|
|
@@ -327,7 +496,47 @@ class CallKitCoordinator {
|
|
|
327
496
|
await VoicePnBridge.clearPendingVoipPush();
|
|
328
497
|
} catch (_) {}
|
|
329
498
|
} finally {
|
|
330
|
-
this.processingCalls.delete(
|
|
499
|
+
this.processingCalls.delete(processingKey);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Handle a UUID-targeted CallKit hold or resume action. */
|
|
504
|
+
public async handleCallKitHeld(callKitUUID: string, isOnHold: boolean): Promise<void> {
|
|
505
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
506
|
+
const processingKey = this.actionKey(isOnHold ? 'hold' : 'unhold', callKitUUID);
|
|
507
|
+
|
|
508
|
+
if (this.processingCalls.has(processingKey)) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const call = this.callMap.get(callKitUUID);
|
|
513
|
+
if (!call) {
|
|
514
|
+
console.warn('CallKitCoordinator: No WebRTC call found for held action', { callKitUUID });
|
|
515
|
+
await CallKit.completeHeldCallAction(callKitUUID, false);
|
|
516
|
+
this.finishPendingHeldRequest(processingKey, false);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
this.processingCalls.add(processingKey);
|
|
521
|
+
try {
|
|
522
|
+
if (isOnHold && call.state !== 'held') {
|
|
523
|
+
await call.hold();
|
|
524
|
+
} else if (!isOnHold && call.state !== 'active') {
|
|
525
|
+
await call.unhold();
|
|
526
|
+
}
|
|
527
|
+
if (!isOnHold) {
|
|
528
|
+
this.selectCall(call);
|
|
529
|
+
}
|
|
530
|
+
const completed = await CallKit.completeHeldCallAction(callKitUUID, true);
|
|
531
|
+
this.recordPendingSwapAction(callKitUUID, isOnHold, completed);
|
|
532
|
+
this.finishPendingHeldRequest(processingKey, completed);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
console.error('CallKitCoordinator: Held action failed', { callKitUUID, isOnHold, error });
|
|
535
|
+
await CallKit.completeHeldCallAction(callKitUUID, false);
|
|
536
|
+
this.recordPendingSwapAction(callKitUUID, isOnHold, false);
|
|
537
|
+
this.finishPendingHeldRequest(processingKey, false);
|
|
538
|
+
} finally {
|
|
539
|
+
this.processingCalls.delete(processingKey);
|
|
331
540
|
}
|
|
332
541
|
}
|
|
333
542
|
|
|
@@ -335,9 +544,12 @@ class CallKitCoordinator {
|
|
|
335
544
|
* Handle CallKit end action (triggered by CallKit)
|
|
336
545
|
*/
|
|
337
546
|
private async handleCallKitEnd(callKitUUID: string, event?: any) {
|
|
338
|
-
|
|
547
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
548
|
+
const wasPendingPush = this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
549
|
+
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
339
550
|
|
|
340
|
-
|
|
551
|
+
const processingKey = this.actionKey('end', callKitUUID);
|
|
552
|
+
if (this.processingCalls.has(processingKey)) {
|
|
341
553
|
console.log('CallKitCoordinator: End action already being processed, skipping duplicate');
|
|
342
554
|
return;
|
|
343
555
|
}
|
|
@@ -372,21 +584,25 @@ class CallKitCoordinator {
|
|
|
372
584
|
webrtcCallId: call.callId,
|
|
373
585
|
});
|
|
374
586
|
|
|
375
|
-
this.
|
|
587
|
+
const shouldRestoreRemainingCall = this.isSelectedCall(call);
|
|
588
|
+
this.processingCalls.add(processingKey);
|
|
376
589
|
|
|
377
590
|
try {
|
|
378
591
|
call.hangup();
|
|
379
592
|
} catch (error) {
|
|
380
593
|
console.error('CallKitCoordinator: Error hanging up WebRTC call', error);
|
|
381
594
|
} finally {
|
|
382
|
-
this.processingCalls.delete(
|
|
595
|
+
this.processingCalls.delete(processingKey);
|
|
383
596
|
this.cleanupCall(callKitUUID);
|
|
597
|
+
if (shouldRestoreRemainingCall) {
|
|
598
|
+
await this.restoreRemainingHeldCall();
|
|
599
|
+
}
|
|
384
600
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
601
|
+
if (wasPendingPush) {
|
|
602
|
+
await VoicePnBridge.clearPendingVoipPush().catch((error) => {
|
|
603
|
+
console.warn('CallKitCoordinator: Failed to clear ended call push data', error);
|
|
604
|
+
});
|
|
605
|
+
}
|
|
390
606
|
|
|
391
607
|
// Check if app is in background and no more calls - disconnect client
|
|
392
608
|
await this.checkBackgroundDisconnection();
|
|
@@ -418,12 +634,28 @@ class CallKitCoordinator {
|
|
|
418
634
|
/**
|
|
419
635
|
* Handle CallKit push received event
|
|
420
636
|
* This allows us to coordinate between the push notification and any subsequent WebRTC calls
|
|
637
|
+
*
|
|
638
|
+
* Returns `true` when the push was accepted and call processing was started,
|
|
639
|
+
* or `false` when the push was filtered, rejected, or otherwise ignored so
|
|
640
|
+
* that callers can clean up any foreground-push lifecycle flags they set
|
|
641
|
+
* before calling this method.
|
|
421
642
|
*/
|
|
422
|
-
async handleCallKitPushReceived(callKitUUID: string, event?: any): Promise<
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
console.log('CallKitCoordinator: Ignoring push
|
|
426
|
-
return;
|
|
643
|
+
async handleCallKitPushReceived(callKitUUID: string, event?: any): Promise<boolean> {
|
|
644
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
645
|
+
if (this.pendingPushCallUUIDs.has(callKitUUID)) {
|
|
646
|
+
console.log('CallKitCoordinator: Ignoring duplicate push UUID', callKitUUID);
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const isRegistered = await CallKit.isCallRegistered(callKitUUID);
|
|
651
|
+
if (!isRegistered) {
|
|
652
|
+
console.warn(
|
|
653
|
+
'CallKitCoordinator: Ignoring push because its CallKit registration was rejected',
|
|
654
|
+
{ callKitUUID }
|
|
655
|
+
);
|
|
656
|
+
this.getSDKClient()?.queueEndFromCallKit(callKitUUID);
|
|
657
|
+
await this.clearMatchingPendingVoipPush(callKitUUID);
|
|
658
|
+
return false;
|
|
427
659
|
}
|
|
428
660
|
|
|
429
661
|
console.log('CallKitCoordinator: Processing push received event', {
|
|
@@ -432,6 +664,7 @@ class CallKitCoordinator {
|
|
|
432
664
|
});
|
|
433
665
|
|
|
434
666
|
this.isCallFromPush = true;
|
|
667
|
+
this.pendingPushCallUUIDs.add(callKitUUID);
|
|
435
668
|
|
|
436
669
|
console.log('CallKitCoordinator: Processing push received event', {
|
|
437
670
|
callKitUUID,
|
|
@@ -443,23 +676,20 @@ class CallKitCoordinator {
|
|
|
443
676
|
// Get VoIP client instance
|
|
444
677
|
const voipClient = this.getSDKClient();
|
|
445
678
|
if (!voipClient) {
|
|
446
|
-
|
|
447
|
-
return;
|
|
679
|
+
throw new Error('CallKitCoordinator: VoIP client not available');
|
|
448
680
|
}
|
|
449
681
|
|
|
450
682
|
// Retrieve pending push data from VoIP bridge
|
|
451
683
|
const pendingPushJson = await VoicePnBridge.getPendingVoipPush();
|
|
452
684
|
if (!pendingPushJson) {
|
|
453
|
-
|
|
454
|
-
return;
|
|
685
|
+
throw new Error('CallKitCoordinator: No pending push data found');
|
|
455
686
|
}
|
|
456
687
|
|
|
457
688
|
const pendingPush = JSON.parse(pendingPushJson);
|
|
458
689
|
const realPushData = pendingPush?.payload;
|
|
459
690
|
|
|
460
691
|
if (!realPushData?.metadata) {
|
|
461
|
-
|
|
462
|
-
return;
|
|
692
|
+
throw new Error('CallKitCoordinator: Invalid push data structure');
|
|
463
693
|
}
|
|
464
694
|
|
|
465
695
|
// Prepare push metadata with CallKit flag
|
|
@@ -468,17 +698,34 @@ class CallKitCoordinator {
|
|
|
468
698
|
from_callkit: true,
|
|
469
699
|
};
|
|
470
700
|
|
|
471
|
-
//
|
|
472
|
-
|
|
701
|
+
// This app-facing UUID is attached to the next inbound Call while the
|
|
702
|
+
// socket INVITE retains its own signaling callID.
|
|
703
|
+
voipClient.setPushNotificationCallKitUUID(callKitUUID);
|
|
704
|
+
|
|
705
|
+
// Restore an answer that native CallKit received before React Native had
|
|
706
|
+
// attached listeners. Never apply an answer UUID to a different incoming call.
|
|
707
|
+
try {
|
|
708
|
+
const pendingAnswer = await VoicePnBridge.getPendingCallKitAnswer();
|
|
709
|
+
if (pendingAnswer && this.normalizeUUID(pendingAnswer) === callKitUUID) {
|
|
710
|
+
console.log('CallKitCoordinator: Restoring pending CallKit answer for matching UUID');
|
|
711
|
+
this.autoAnswerCallUUIDs.add(callKitUUID);
|
|
712
|
+
await VoicePnBridge.clearPendingCallKitAnswer();
|
|
713
|
+
}
|
|
714
|
+
} catch {
|
|
715
|
+
// The bridge method is unavailable on older native builds.
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// A pre-INVITE CallKit answer is represented only by the UUID-keyed
|
|
719
|
+
// pending action. Do not also add legacy notification flags.
|
|
720
|
+
const shouldAddFromNotification = this.autoAnswerCallUUIDs.has(callKitUUID);
|
|
473
721
|
|
|
474
722
|
let pushData;
|
|
475
723
|
if (shouldAddFromNotification) {
|
|
476
724
|
pushData = {
|
|
477
725
|
metadata: enhancedMetadata,
|
|
478
|
-
from_notification: true,
|
|
479
|
-
action: 'answer',
|
|
480
726
|
};
|
|
481
|
-
voipClient.queueAnswerFromCallKit();
|
|
727
|
+
voipClient.queueAnswerFromCallKit(callKitUUID);
|
|
728
|
+
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
482
729
|
} else {
|
|
483
730
|
pushData = {
|
|
484
731
|
metadata: enhancedMetadata,
|
|
@@ -488,8 +735,12 @@ class CallKitCoordinator {
|
|
|
488
735
|
// Process the push notification
|
|
489
736
|
await voipClient.handlePushNotification(pushData);
|
|
490
737
|
console.log('CallKitCoordinator: Push notification processed successfully');
|
|
738
|
+
return true;
|
|
491
739
|
} catch (error) {
|
|
740
|
+
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
741
|
+
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
492
742
|
console.error('CallKitCoordinator: Error processing push received event:', error);
|
|
743
|
+
return false;
|
|
493
744
|
}
|
|
494
745
|
}
|
|
495
746
|
|
|
@@ -507,75 +758,31 @@ class CallKitCoordinator {
|
|
|
507
758
|
if (Platform.OS === 'ios') {
|
|
508
759
|
console.log('CallKitCoordinator: Processing iOS push notification answer');
|
|
509
760
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
console.log('CallKitCoordinator: ✅ Set auto-answer flag for next incoming call');
|
|
761
|
+
this.autoAnswerCallUUIDs.add(callKitUUID);
|
|
762
|
+
console.log('CallKitCoordinator: Set UUID-targeted auto-answer', callKitUUID);
|
|
513
763
|
|
|
514
764
|
// Try to get VoIP client - it may not be wired yet if user answered
|
|
515
765
|
// from CallKit before React Native finished initializing
|
|
516
766
|
const voipClient = this.getSDKClient();
|
|
517
767
|
if (!voipClient) {
|
|
518
768
|
// voipClient not ready yet - DON'T fail the call.
|
|
519
|
-
//
|
|
769
|
+
// The UUID-targeted auto-answer is already stored above.
|
|
520
770
|
// checkForInitialPushNotification() will run after setVoipClient()
|
|
521
771
|
// and will find the push data still intact, call handleCallKitPushReceived()
|
|
522
|
-
// which
|
|
772
|
+
// which queues the matching auto-answer.
|
|
523
773
|
return;
|
|
524
774
|
}
|
|
525
775
|
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
voipClient.queueAnswerFromCallKit();
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
try {
|
|
534
|
-
const pendingPushJson = await VoicePnBridge.getPendingVoipPush();
|
|
535
|
-
if (pendingPushJson) {
|
|
536
|
-
const pendingPush = JSON.parse(pendingPushJson);
|
|
537
|
-
if (pendingPush && pendingPush.payload) {
|
|
538
|
-
console.log('CallKitCoordinator: ✅ Found real push data');
|
|
539
|
-
realPushData = pendingPush.payload;
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
} catch (error) {
|
|
543
|
-
console.warn('CallKitCoordinator: Could not get real push data:', error);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Create push notification payload - use real data if available, fallback to placeholder
|
|
547
|
-
const pushAction = 'incoming_call';
|
|
548
|
-
let pushMetadata: string;
|
|
549
|
-
|
|
550
|
-
if (realPushData && realPushData.metadata) {
|
|
551
|
-
// Use the real push metadata
|
|
552
|
-
console.log('CallKitCoordinator: 🎯 Using REAL push metadata for immediate handling');
|
|
553
|
-
pushMetadata = JSON.stringify({
|
|
554
|
-
...realPushData.metadata,
|
|
555
|
-
from_callkit: true, // Add flag to indicate this was answered via CallKit
|
|
556
|
-
});
|
|
557
|
-
} else {
|
|
558
|
-
// Fallback to placeholder (this should rarely happen)
|
|
559
|
-
console.warn('CallKitCoordinator: ⚠️ No real push data found, using placeholder');
|
|
560
|
-
pushMetadata = JSON.stringify({
|
|
561
|
-
call_id: callKitUUID,
|
|
562
|
-
caller_name: 'Incoming Call',
|
|
563
|
-
caller_number: 'Unknown',
|
|
564
|
-
voice_sdk_id: 'unknown',
|
|
565
|
-
sent_time: new Date().toISOString(),
|
|
566
|
-
from_callkit: true,
|
|
567
|
-
});
|
|
776
|
+
// Queue one UUID-targeted answer. If the push is already being
|
|
777
|
+
// processed, its eventual INVITE will consume this action. Otherwise
|
|
778
|
+
// process the still-persisted VoIP payload now.
|
|
779
|
+
voipClient.queueAnswerFromCallKit(callKitUUID);
|
|
780
|
+
if (this.pendingPushCallUUIDs.has(callKitUUID)) {
|
|
781
|
+
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
782
|
+
return;
|
|
568
783
|
}
|
|
569
784
|
|
|
570
|
-
|
|
571
|
-
await VoicePnBridge.setPendingPushAction(pushAction, pushMetadata);
|
|
572
|
-
console.log('CallKitCoordinator: ✅ Set pending push action');
|
|
573
|
-
|
|
574
|
-
// Clear push data now that push notification answer is handled
|
|
575
|
-
try {
|
|
576
|
-
await VoicePnBridge.clearPendingVoipPush();
|
|
577
|
-
console.log('CallKitCoordinator: Cleared pending VoIP push after push answer handled');
|
|
578
|
-
} catch (_) {}
|
|
785
|
+
await this.handleCallKitPushReceived(callKitUUID, event);
|
|
579
786
|
|
|
580
787
|
return;
|
|
581
788
|
}
|
|
@@ -605,7 +812,7 @@ class CallKitCoordinator {
|
|
|
605
812
|
if (Platform.OS === 'ios') {
|
|
606
813
|
console.log('CallKitCoordinator: Processing iOS push notification rejection');
|
|
607
814
|
|
|
608
|
-
this.
|
|
815
|
+
this.getSDKClient()?.queueEndFromCallKit(callKitUUID);
|
|
609
816
|
|
|
610
817
|
// Clean up push notification state
|
|
611
818
|
await this.cleanupPushNotificationState();
|
|
@@ -642,6 +849,10 @@ class CallKitCoordinator {
|
|
|
642
849
|
|
|
643
850
|
switch (state) {
|
|
644
851
|
case 'active':
|
|
852
|
+
this.selectCall(call);
|
|
853
|
+
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
854
|
+
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
855
|
+
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
645
856
|
// When WebRTC call becomes active, just report as connected
|
|
646
857
|
// (CallKit call was already answered in answerCallFromUI)
|
|
647
858
|
if (!this.connectedCalls.has(callKitUUID)) {
|
|
@@ -657,7 +868,10 @@ class CallKitCoordinator {
|
|
|
657
868
|
break;
|
|
658
869
|
|
|
659
870
|
case 'ended':
|
|
660
|
-
case 'failed':
|
|
871
|
+
case 'failed': {
|
|
872
|
+
const shouldRestoreRemainingCall =
|
|
873
|
+
this.selectedCallKitUUID === callKitUUID &&
|
|
874
|
+
!this.processingCalls.has(this.actionKey('end', callKitUUID));
|
|
661
875
|
// Report call ended to CallKit (if not already ended)
|
|
662
876
|
if (!this.endedCalls.has(callKitUUID)) {
|
|
663
877
|
console.log('CallKitCoordinator: Reporting call ended to CallKit');
|
|
@@ -668,7 +882,11 @@ class CallKitCoordinator {
|
|
|
668
882
|
|
|
669
883
|
// Clean up the call mapping
|
|
670
884
|
this.cleanupCall(callKitUUID);
|
|
885
|
+
if (shouldRestoreRemainingCall) {
|
|
886
|
+
await this.restoreRemainingHeldCall();
|
|
887
|
+
}
|
|
671
888
|
break;
|
|
889
|
+
}
|
|
672
890
|
|
|
673
891
|
case 'ringing':
|
|
674
892
|
// For outgoing calls, we might want to update CallKit with additional info
|
|
@@ -689,8 +907,26 @@ class CallKitCoordinator {
|
|
|
689
907
|
* Clean up call mappings and listeners
|
|
690
908
|
*/
|
|
691
909
|
private cleanupCall(callKitUUID: string) {
|
|
692
|
-
this.
|
|
910
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
911
|
+
if (
|
|
912
|
+
this.pendingSwap?.activeCallKitUUID === callKitUUID ||
|
|
913
|
+
this.pendingSwap?.heldCallKitUUID === callKitUUID
|
|
914
|
+
) {
|
|
915
|
+
this.finishPendingSwap(false);
|
|
916
|
+
}
|
|
917
|
+
for (const processingKey of this.processingCalls) {
|
|
918
|
+
if (processingKey.endsWith(`:${callKitUUID}`)) {
|
|
919
|
+
this.processingCalls.delete(processingKey);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
for (const requestKey of this.pendingHeldRequests.keys()) {
|
|
923
|
+
if (requestKey.endsWith(`:${callKitUUID}`)) {
|
|
924
|
+
this.finishPendingHeldRequest(requestKey, false);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
693
927
|
this.connectedCalls.delete(callKitUUID);
|
|
928
|
+
this.pendingPushCallUUIDs.delete(callKitUUID);
|
|
929
|
+
this.autoAnswerCallUUIDs.delete(callKitUUID);
|
|
694
930
|
|
|
695
931
|
// Get the call before removing it
|
|
696
932
|
const call = this.callMap.get(callKitUUID);
|
|
@@ -704,9 +940,8 @@ class CallKitCoordinator {
|
|
|
704
940
|
// Remove from mapping
|
|
705
941
|
this.callMap.delete(callKitUUID);
|
|
706
942
|
|
|
707
|
-
if (
|
|
708
|
-
|
|
709
|
-
delete (call as any)._callKitUUID;
|
|
943
|
+
if (this.selectedCallKitUUID === callKitUUID) {
|
|
944
|
+
this.selectedCallKitUUID = null;
|
|
710
945
|
}
|
|
711
946
|
|
|
712
947
|
// Reset flags if no more active calls
|
|
@@ -714,13 +949,7 @@ class CallKitCoordinator {
|
|
|
714
949
|
this.resetFlags();
|
|
715
950
|
}
|
|
716
951
|
|
|
717
|
-
|
|
718
|
-
if (Platform.OS === 'ios') {
|
|
719
|
-
VoicePnBridge.clearPendingVoipPush().catch((error) => {
|
|
720
|
-
console.warn('CallKitCoordinator: Error clearing VoIP push data on call cleanup:', error);
|
|
721
|
-
});
|
|
722
|
-
console.log('CallKitCoordinator: ✅ Cleared VoIP push data after call ended');
|
|
723
|
-
}
|
|
952
|
+
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
724
953
|
}
|
|
725
954
|
|
|
726
955
|
/**
|
|
@@ -730,15 +959,17 @@ class CallKitCoordinator {
|
|
|
730
959
|
// First check if the call has the UUID stored on it
|
|
731
960
|
const storedUUID = (call as any)._callKitUUID;
|
|
732
961
|
if (storedUUID) {
|
|
733
|
-
return storedUUID;
|
|
962
|
+
return this.normalizeUUID(storedUUID);
|
|
734
963
|
}
|
|
735
964
|
|
|
736
965
|
// Search through all call mappings
|
|
737
966
|
for (const [uuid, mappedCall] of this.callMap.entries()) {
|
|
738
967
|
if (mappedCall.callId === call.callId) {
|
|
739
968
|
// Store UUID on the call for faster future lookups
|
|
740
|
-
(call as any)._callKitUUID
|
|
741
|
-
|
|
969
|
+
if (!(call as any)._callKitUUID) {
|
|
970
|
+
(call as any)._callKitUUID = uuid;
|
|
971
|
+
}
|
|
972
|
+
return this.normalizeUUID(uuid);
|
|
742
973
|
}
|
|
743
974
|
}
|
|
744
975
|
|
|
@@ -749,7 +980,7 @@ class CallKitCoordinator {
|
|
|
749
980
|
* Get WebRTC call for a CallKit UUID
|
|
750
981
|
*/
|
|
751
982
|
getWebRTCCall(callKitUUID: string): Call | null {
|
|
752
|
-
return this.callMap.get(callKitUUID) || null;
|
|
983
|
+
return this.callMap.get(this.normalizeUUID(callKitUUID)) || null;
|
|
753
984
|
}
|
|
754
985
|
|
|
755
986
|
/**
|
|
@@ -757,6 +988,7 @@ class CallKitCoordinator {
|
|
|
757
988
|
* This should be called when a WebRTC call arrives that corresponds to an existing CallKit call
|
|
758
989
|
*/
|
|
759
990
|
linkExistingCallKitCall(call: Call, callKitUUID: string): void {
|
|
991
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
760
992
|
console.log('CallKitCoordinator: Linking existing CallKit call with WebRTC call', {
|
|
761
993
|
callKitUUID,
|
|
762
994
|
webrtcCallId: call.callId,
|
|
@@ -765,11 +997,15 @@ class CallKitCoordinator {
|
|
|
765
997
|
// Store the mappings
|
|
766
998
|
this.callMap.set(callKitUUID, call);
|
|
767
999
|
|
|
768
|
-
//
|
|
769
|
-
(call as any)._callKitUUID
|
|
1000
|
+
// Push-created calls already carry a durable, non-configurable UUID.
|
|
1001
|
+
if (!(call as any)._callKitUUID) {
|
|
1002
|
+
(call as any)._callKitUUID = callKitUUID;
|
|
1003
|
+
}
|
|
770
1004
|
|
|
771
1005
|
// Set up state listeners
|
|
772
|
-
|
|
1006
|
+
if (!(call as any)._callKitStateListener) {
|
|
1007
|
+
this.setupWebRTCCallListeners(call, callKitUUID);
|
|
1008
|
+
}
|
|
773
1009
|
}
|
|
774
1010
|
|
|
775
1011
|
/**
|
|
@@ -786,8 +1022,51 @@ class CallKitCoordinator {
|
|
|
786
1022
|
* Helper method to clean up push notification state
|
|
787
1023
|
*/
|
|
788
1024
|
private async cleanupPushNotificationState(): Promise<void> {
|
|
789
|
-
console.log('CallKitCoordinator:
|
|
790
|
-
|
|
1025
|
+
console.log('CallKitCoordinator: Push notification state cleaned up');
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
private async rejectUnregisteredIncomingCall(callKitUUID: string, call: Call): Promise<void> {
|
|
1029
|
+
callKitUUID = this.normalizeUUID(callKitUUID);
|
|
1030
|
+
|
|
1031
|
+
console.warn('CallKitCoordinator: Cleaning up unregistered incoming call', {
|
|
1032
|
+
callKitUUID,
|
|
1033
|
+
webrtcCallId: call.callId,
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
this.cleanupCall(callKitUUID);
|
|
1037
|
+
|
|
1038
|
+
try {
|
|
1039
|
+
await call.hangup();
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
console.warn('CallKitCoordinator: Failed to end unregistered signaling call', error);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
await this.clearMatchingPendingVoipPush(callKitUUID);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
private async clearMatchingPendingVoipPush(callKitUUID: string): Promise<void> {
|
|
1048
|
+
try {
|
|
1049
|
+
const pendingPushJson = await VoicePnBridge.getPendingVoipPush();
|
|
1050
|
+
if (!pendingPushJson) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const pendingPush = JSON.parse(pendingPushJson);
|
|
1055
|
+
const pendingCallId =
|
|
1056
|
+
pendingPush?.payload?.metadata?.call_id ??
|
|
1057
|
+
pendingPush?.payload?.call_id ??
|
|
1058
|
+
pendingPush?.metadata?.call_id ??
|
|
1059
|
+
pendingPush?.call_id;
|
|
1060
|
+
|
|
1061
|
+
if (
|
|
1062
|
+
typeof pendingCallId === 'string' &&
|
|
1063
|
+
this.normalizeUUID(pendingCallId) === this.normalizeUUID(callKitUUID)
|
|
1064
|
+
) {
|
|
1065
|
+
await VoicePnBridge.clearPendingVoipPush();
|
|
1066
|
+
}
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
console.warn('CallKitCoordinator: Failed to clear matching pending VoIP push', error);
|
|
1069
|
+
}
|
|
791
1070
|
}
|
|
792
1071
|
|
|
793
1072
|
/**
|
|
@@ -797,6 +1076,213 @@ class CallKitCoordinator {
|
|
|
797
1076
|
return this.voipClient;
|
|
798
1077
|
}
|
|
799
1078
|
|
|
1079
|
+
private normalizeUUID(callKitUUID: string): string {
|
|
1080
|
+
return callKitUUID.toLowerCase();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private actionKey(action: string, callKitUUID: string): string {
|
|
1084
|
+
return `${action}:${this.normalizeUUID(callKitUUID)}`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
private selectCall(call: Call): void {
|
|
1088
|
+
const callKitUUID = this.getCallKitUUID(call);
|
|
1089
|
+
const wrapperCall = this.voipClient?.findCallByTelnyxCall(call);
|
|
1090
|
+
if (!callKitUUID || !wrapperCall || !this.voipClient) {
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
this.selectedCallKitUUID = callKitUUID;
|
|
1095
|
+
this.voipClient.setActiveCall(wrapperCall.callId);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
private isSelectedCall(call: Call): boolean {
|
|
1099
|
+
const callKitUUID = this.getCallKitUUID(call);
|
|
1100
|
+
return callKitUUID !== null && callKitUUID === this.selectedCallKitUUID;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
private recordPendingSwapAction(callKitUUID: string, isOnHold: boolean, success: boolean): void {
|
|
1104
|
+
const pendingSwap = this.pendingSwap;
|
|
1105
|
+
if (!pendingSwap || pendingSwap.mode === 'restoring') {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const isExpectedAction =
|
|
1110
|
+
(callKitUUID === pendingSwap.activeCallKitUUID && isOnHold) ||
|
|
1111
|
+
(callKitUUID === pendingSwap.heldCallKitUUID && !isOnHold);
|
|
1112
|
+
if (!isExpectedAction) {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
pendingSwap.failed ||= !success;
|
|
1117
|
+
pendingSwap.completedCallKitUUIDs.add(callKitUUID);
|
|
1118
|
+
if (pendingSwap.completedCallKitUUIDs.size === 2) {
|
|
1119
|
+
if (pendingSwap.mode === 'swap' && pendingSwap.failed) {
|
|
1120
|
+
void this.beginSwapRollback(pendingSwap);
|
|
1121
|
+
} else {
|
|
1122
|
+
this.finishPendingSwap(pendingSwap.mode === 'swap');
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
private createPendingSwap({
|
|
1128
|
+
mode,
|
|
1129
|
+
originalActiveCallKitUUID,
|
|
1130
|
+
originalHeldCallKitUUID,
|
|
1131
|
+
activeCallKitUUID,
|
|
1132
|
+
heldCallKitUUID,
|
|
1133
|
+
resolve,
|
|
1134
|
+
}: Omit<PendingSwap, 'completedCallKitUUIDs' | 'failed' | 'timeout'>): PendingSwap {
|
|
1135
|
+
const pendingSwap = {
|
|
1136
|
+
mode,
|
|
1137
|
+
originalActiveCallKitUUID,
|
|
1138
|
+
originalHeldCallKitUUID,
|
|
1139
|
+
activeCallKitUUID,
|
|
1140
|
+
heldCallKitUUID,
|
|
1141
|
+
completedCallKitUUIDs: new Set<string>(),
|
|
1142
|
+
failed: false,
|
|
1143
|
+
resolve,
|
|
1144
|
+
timeout: undefined as unknown as ReturnType<typeof setTimeout>,
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
pendingSwap.timeout = setTimeout(() => {
|
|
1148
|
+
if (this.pendingSwap !== pendingSwap) {
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
console.error(`CallKitCoordinator: Call ${mode} timed out`);
|
|
1153
|
+
if (mode === 'swap') {
|
|
1154
|
+
void this.beginSwapRollback(pendingSwap);
|
|
1155
|
+
} else {
|
|
1156
|
+
this.finishPendingSwap(false);
|
|
1157
|
+
}
|
|
1158
|
+
}, 12_000);
|
|
1159
|
+
|
|
1160
|
+
return pendingSwap;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
private async beginSwapRollback(pendingSwap: PendingSwap | null): Promise<void> {
|
|
1164
|
+
if (!pendingSwap || this.pendingSwap !== pendingSwap || pendingSwap.mode !== 'swap') {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
clearTimeout(pendingSwap.timeout);
|
|
1169
|
+
// Keep the operation installed while restoring WebRTC state so another
|
|
1170
|
+
// hold or swap cannot enter and have its pending promise overwritten.
|
|
1171
|
+
pendingSwap.mode = 'restoring';
|
|
1172
|
+
|
|
1173
|
+
await this.restoreOriginalWebRTCStates(
|
|
1174
|
+
pendingSwap.originalActiveCallKitUUID,
|
|
1175
|
+
pendingSwap.originalHeldCallKitUUID
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
// A call may have ended while restoration was in flight, in which case
|
|
1179
|
+
// cleanup already resolved and removed this operation.
|
|
1180
|
+
if (this.pendingSwap !== pendingSwap) {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const rollback = this.createPendingSwap({
|
|
1185
|
+
mode: 'rollback',
|
|
1186
|
+
originalActiveCallKitUUID: pendingSwap.originalActiveCallKitUUID,
|
|
1187
|
+
originalHeldCallKitUUID: pendingSwap.originalHeldCallKitUUID,
|
|
1188
|
+
// A reverse swap requests the original held call to remain held and the
|
|
1189
|
+
// original active call to become active again.
|
|
1190
|
+
activeCallKitUUID: pendingSwap.originalHeldCallKitUUID,
|
|
1191
|
+
heldCallKitUUID: pendingSwap.originalActiveCallKitUUID,
|
|
1192
|
+
resolve: pendingSwap.resolve,
|
|
1193
|
+
});
|
|
1194
|
+
this.pendingSwap = rollback;
|
|
1195
|
+
|
|
1196
|
+
const accepted = await CallKit.swapCalls(rollback.activeCallKitUUID, rollback.heldCallKitUUID);
|
|
1197
|
+
if (!accepted && this.pendingSwap === rollback) {
|
|
1198
|
+
console.error('CallKitCoordinator: Compensating swap transaction was rejected');
|
|
1199
|
+
this.finishPendingSwap(false);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
private async restoreOriginalWebRTCStates(
|
|
1204
|
+
activeCallKitUUID: string,
|
|
1205
|
+
heldCallKitUUID: string
|
|
1206
|
+
): Promise<void> {
|
|
1207
|
+
const originalActive = this.callMap.get(activeCallKitUUID);
|
|
1208
|
+
const originalHeld = this.callMap.get(heldCallKitUUID);
|
|
1209
|
+
|
|
1210
|
+
try {
|
|
1211
|
+
if (originalHeld?.state === 'active') {
|
|
1212
|
+
await originalHeld.hold();
|
|
1213
|
+
}
|
|
1214
|
+
if (originalActive?.state === 'held') {
|
|
1215
|
+
await originalActive.unhold();
|
|
1216
|
+
}
|
|
1217
|
+
if (originalActive?.state === 'active') {
|
|
1218
|
+
this.selectCall(originalActive);
|
|
1219
|
+
}
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
console.error('CallKitCoordinator: Failed to restore WebRTC state before swap rollback', {
|
|
1222
|
+
activeCallKitUUID,
|
|
1223
|
+
heldCallKitUUID,
|
|
1224
|
+
error,
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
private finishPendingSwap(success: boolean): void {
|
|
1230
|
+
const pendingSwap = this.pendingSwap;
|
|
1231
|
+
if (!pendingSwap) {
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
this.pendingSwap = null;
|
|
1236
|
+
clearTimeout(pendingSwap.timeout);
|
|
1237
|
+
pendingSwap.resolve(success);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
private finishPendingHeldRequest(requestKey: string, success: boolean): void {
|
|
1241
|
+
const request = this.pendingHeldRequests.get(requestKey);
|
|
1242
|
+
if (!request) {
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
this.pendingHeldRequests.delete(requestKey);
|
|
1247
|
+
clearTimeout(request.timeout);
|
|
1248
|
+
request.resolve(success);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
private async restoreRemainingHeldCall(): Promise<void> {
|
|
1252
|
+
if (this.restoreHeldCallPromise) {
|
|
1253
|
+
return this.restoreHeldCallPromise;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
this.restoreHeldCallPromise = (async () => {
|
|
1257
|
+
const heldEntry = Array.from(this.callMap.entries()).find(
|
|
1258
|
+
([, call]) => call.state === 'held'
|
|
1259
|
+
);
|
|
1260
|
+
if (!heldEntry) {
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const [callKitUUID, heldCall] = heldEntry;
|
|
1265
|
+
try {
|
|
1266
|
+
const restored = await this.setHeldFromUI(heldCall, false);
|
|
1267
|
+
if (!restored) {
|
|
1268
|
+
throw new Error('CallKit rejected survivor resume');
|
|
1269
|
+
}
|
|
1270
|
+
console.log('CallKitCoordinator: Restored remaining held call', callKitUUID);
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
console.error('CallKitCoordinator: Failed to restore remaining held call', {
|
|
1273
|
+
callKitUUID,
|
|
1274
|
+
error,
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
})();
|
|
1278
|
+
|
|
1279
|
+
try {
|
|
1280
|
+
await this.restoreHeldCallPromise;
|
|
1281
|
+
} finally {
|
|
1282
|
+
this.restoreHeldCallPromise = null;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
800
1286
|
/**
|
|
801
1287
|
* Check if app is in background and disconnect client if no active calls
|
|
802
1288
|
*/
|
|
@@ -834,11 +1320,7 @@ class CallKitCoordinator {
|
|
|
834
1320
|
resetFlags(): void {
|
|
835
1321
|
console.log('CallKitCoordinator: Resetting coordinator flags');
|
|
836
1322
|
|
|
837
|
-
|
|
838
|
-
this.isCallFromPush = false;
|
|
839
|
-
|
|
840
|
-
// Reset auto-answer flag
|
|
841
|
-
this.shouldAutoAnswerNextCall = false;
|
|
1323
|
+
this.isCallFromPush = this.pendingPushCallUUIDs.size > 0;
|
|
842
1324
|
|
|
843
1325
|
console.log('CallKitCoordinator: ✅ Coordinator flags reset');
|
|
844
1326
|
}
|