@telnyx/react-voice-commons-sdk 1.0.0 → 1.1.1
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 +27 -1
- package/ios/CallKitBridge.swift +640 -125
- 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 +7 -5
- 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
package/ios/CallKitBridge.swift
CHANGED
|
@@ -9,6 +9,49 @@ import React
|
|
|
9
9
|
import UserNotifications
|
|
10
10
|
import WebRTC
|
|
11
11
|
|
|
12
|
+
/// Small synchronized dictionary used because React Native module methods
|
|
13
|
+
/// and CXProvider delegate callbacks are not guaranteed to share a queue.
|
|
14
|
+
public final class LockedDictionary<Key: Hashable, Value> {
|
|
15
|
+
private var storage: [Key: Value] = [:]
|
|
16
|
+
private let lock: NSRecursiveLock
|
|
17
|
+
|
|
18
|
+
init(lock: NSRecursiveLock) {
|
|
19
|
+
self.lock = lock
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public subscript(key: Key) -> Value? {
|
|
23
|
+
get {
|
|
24
|
+
lock.lock()
|
|
25
|
+
defer { lock.unlock() }
|
|
26
|
+
return storage[key]
|
|
27
|
+
}
|
|
28
|
+
set {
|
|
29
|
+
lock.lock()
|
|
30
|
+
defer { lock.unlock() }
|
|
31
|
+
storage[key] = newValue
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public var values: [Value] {
|
|
36
|
+
lock.lock()
|
|
37
|
+
defer { lock.unlock() }
|
|
38
|
+
return Array(storage.values)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@discardableResult
|
|
42
|
+
public func removeValue(forKey key: Key) -> Value? {
|
|
43
|
+
lock.lock()
|
|
44
|
+
defer { lock.unlock() }
|
|
45
|
+
return storage.removeValue(forKey: key)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public func removeAll() {
|
|
49
|
+
lock.lock()
|
|
50
|
+
defer { lock.unlock() }
|
|
51
|
+
storage.removeAll()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
12
55
|
@objc(CallKitBridge)
|
|
13
56
|
class CallKitBridge: RCTEventEmitter {
|
|
14
57
|
|
|
@@ -29,6 +72,7 @@ import React
|
|
|
29
72
|
"CallKitDidReceiveStartCallAction",
|
|
30
73
|
"CallKitDidPerformAnswerCallAction",
|
|
31
74
|
"CallKitDidPerformEndCallAction",
|
|
75
|
+
"CallKitDidPerformHeldCallAction",
|
|
32
76
|
"CallKitDidReceivePush",
|
|
33
77
|
"AudioSessionActivated",
|
|
34
78
|
"AudioSessionDeactivated",
|
|
@@ -50,8 +94,9 @@ import React
|
|
|
50
94
|
}
|
|
51
95
|
|
|
52
96
|
// Direct event emission methods called by TelnyxVoiceAppDelegate
|
|
53
|
-
|
|
54
|
-
|
|
97
|
+
@discardableResult
|
|
98
|
+
public func emitCallEvent(_ eventName: String, callUUID: UUID, callData: [String: Any]?) -> Bool {
|
|
99
|
+
guard hasListeners else { return false }
|
|
55
100
|
|
|
56
101
|
let eventData: [String: Any] = [
|
|
57
102
|
"callUUID": callUUID.uuidString,
|
|
@@ -59,6 +104,22 @@ import React
|
|
|
59
104
|
]
|
|
60
105
|
|
|
61
106
|
sendEvent(withName: eventName, body: eventData)
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@discardableResult
|
|
111
|
+
public func emitHeldCallEvent(
|
|
112
|
+
callUUID: UUID, isOnHold: Bool, callData: [String: Any]?
|
|
113
|
+
) -> Bool {
|
|
114
|
+
guard hasListeners else { return false }
|
|
115
|
+
|
|
116
|
+
let eventData: [String: Any] = [
|
|
117
|
+
"callUUID": callUUID.uuidString,
|
|
118
|
+
"isOnHold": isOnHold,
|
|
119
|
+
"callData": callData ?? [:],
|
|
120
|
+
]
|
|
121
|
+
sendEvent(withName: "CallKitDidPerformHeldCallAction", body: eventData)
|
|
122
|
+
return true
|
|
62
123
|
}
|
|
63
124
|
|
|
64
125
|
// Event emission method for audio session changes
|
|
@@ -90,8 +151,17 @@ import React
|
|
|
90
151
|
let startCallAction = CXStartCallAction(call: uuid, handle: callHandle)
|
|
91
152
|
let transaction = CXTransaction(action: startCallAction)
|
|
92
153
|
|
|
154
|
+
manager.activeCalls[uuid] = [
|
|
155
|
+
"caller": displayName,
|
|
156
|
+
"handle": handle,
|
|
157
|
+
"uuid": uuid.uuidString,
|
|
158
|
+
"direction": "outgoing",
|
|
159
|
+
"source": "react_native",
|
|
160
|
+
]
|
|
161
|
+
|
|
93
162
|
callController.request(transaction) { error in
|
|
94
163
|
if let error = error {
|
|
164
|
+
manager.activeCalls.removeValue(forKey: uuid)
|
|
95
165
|
reject("START_CALL_ERROR", error.localizedDescription, error)
|
|
96
166
|
} else {
|
|
97
167
|
resolve(["success": true, "callUUID": callUUID])
|
|
@@ -120,11 +190,23 @@ import React
|
|
|
120
190
|
callUpdate.remoteHandle = callHandle
|
|
121
191
|
callUpdate.hasVideo = false
|
|
122
192
|
callUpdate.localizedCallerName = displayName
|
|
193
|
+
callUpdate.supportsHolding = true
|
|
194
|
+
|
|
195
|
+
manager.activeCalls[uuid] = [
|
|
196
|
+
"caller": displayName,
|
|
197
|
+
"handle": handle,
|
|
198
|
+
"uuid": uuid.uuidString,
|
|
199
|
+
"direction": "incoming",
|
|
200
|
+
"source": "react_native",
|
|
201
|
+
"isCallKitRegistered": false,
|
|
202
|
+
]
|
|
123
203
|
|
|
124
204
|
provider.reportNewIncomingCall(with: uuid, update: callUpdate) { error in
|
|
125
205
|
if let error = error {
|
|
206
|
+
manager.activeCalls.removeValue(forKey: uuid)
|
|
126
207
|
reject("INCOMING_CALL_ERROR", error.localizedDescription, error)
|
|
127
208
|
} else {
|
|
209
|
+
manager.markCallKitRegistered(uuid)
|
|
128
210
|
resolve(["success": true, "callUUID": callUUID])
|
|
129
211
|
}
|
|
130
212
|
}
|
|
@@ -157,6 +239,127 @@ import React
|
|
|
157
239
|
}
|
|
158
240
|
}
|
|
159
241
|
|
|
242
|
+
@objc func completeHeldCallAction(
|
|
243
|
+
_ callUUID: String, success: Bool,
|
|
244
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
245
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
246
|
+
) {
|
|
247
|
+
guard let uuid = UUID(uuidString: callUUID) else {
|
|
248
|
+
reject("INVALID_UUID", "Invalid UUID format", nil)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let manager = getCallKitManager()
|
|
253
|
+
let completeAction = {
|
|
254
|
+
guard let action = manager.pendingHeldActions.removeValue(forKey: uuid) else {
|
|
255
|
+
reject("NO_PENDING_HELD_ACTION", "No pending held action for UUID", nil)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if success {
|
|
260
|
+
if var callData = manager.activeCalls[uuid] {
|
|
261
|
+
callData["isOnHold"] = action.isOnHold
|
|
262
|
+
manager.activeCalls[uuid] = callData
|
|
263
|
+
}
|
|
264
|
+
action.fulfill()
|
|
265
|
+
} else {
|
|
266
|
+
action.fail()
|
|
267
|
+
}
|
|
268
|
+
resolve(["success": success, "callUUID": callUUID])
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if Thread.isMainThread {
|
|
272
|
+
completeAction()
|
|
273
|
+
} else {
|
|
274
|
+
DispatchQueue.main.async(execute: completeAction)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@objc func setCallHeld(
|
|
279
|
+
_ callUUID: String, isOnHold: Bool,
|
|
280
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
281
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
282
|
+
) {
|
|
283
|
+
guard let uuid = UUID(uuidString: callUUID) else {
|
|
284
|
+
reject("INVALID_UUID", "Invalid UUID format", nil)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let manager = getCallKitManager()
|
|
289
|
+
guard let callController = manager.callKitController else {
|
|
290
|
+
reject("NO_CALLKIT", "CallKit not available", nil)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
guard manager.activeCalls[uuid] != nil else {
|
|
295
|
+
reject("UNKNOWN_CALL", "Call must be registered with CallKit", nil)
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let action = CXSetHeldCallAction(call: uuid, onHold: isOnHold)
|
|
300
|
+
let transaction = CXTransaction(action: action)
|
|
301
|
+
callController.request(transaction) { error in
|
|
302
|
+
if let error = error {
|
|
303
|
+
reject("SET_HELD_ERROR", error.localizedDescription, error)
|
|
304
|
+
} else {
|
|
305
|
+
resolve([
|
|
306
|
+
"success": true,
|
|
307
|
+
"callUUID": callUUID,
|
|
308
|
+
"isOnHold": isOnHold,
|
|
309
|
+
])
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
@objc func swapCalls(
|
|
315
|
+
_ activeCallUUID: String, heldCallUUID: String,
|
|
316
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
317
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
318
|
+
) {
|
|
319
|
+
guard let activeUUID = UUID(uuidString: activeCallUUID),
|
|
320
|
+
let heldUUID = UUID(uuidString: heldCallUUID)
|
|
321
|
+
else {
|
|
322
|
+
reject("INVALID_UUID", "Invalid UUID format", nil)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
guard activeUUID != heldUUID else {
|
|
327
|
+
reject("INVALID_SWAP", "Active and held call UUIDs must be different", nil)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let manager = getCallKitManager()
|
|
332
|
+
guard let callController = manager.callKitController else {
|
|
333
|
+
reject("NO_CALLKIT", "CallKit not available", nil)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
guard manager.activeCalls[activeUUID] != nil,
|
|
338
|
+
manager.activeCalls[heldUUID] != nil
|
|
339
|
+
else {
|
|
340
|
+
reject("UNKNOWN_CALL", "Both calls must be registered with CallKit", nil)
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let actions: [CXAction] = [
|
|
345
|
+
CXSetHeldCallAction(call: activeUUID, onHold: true),
|
|
346
|
+
CXSetHeldCallAction(call: heldUUID, onHold: false),
|
|
347
|
+
]
|
|
348
|
+
let transaction = CXTransaction(actions: actions)
|
|
349
|
+
|
|
350
|
+
callController.request(transaction) { error in
|
|
351
|
+
if let error = error {
|
|
352
|
+
reject("SWAP_CALLS_ERROR", error.localizedDescription, error)
|
|
353
|
+
} else {
|
|
354
|
+
resolve([
|
|
355
|
+
"success": true,
|
|
356
|
+
"activeCallUUID": activeCallUUID,
|
|
357
|
+
"heldCallUUID": heldCallUUID,
|
|
358
|
+
])
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
160
363
|
@objc func answerCall(
|
|
161
364
|
_ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
162
365
|
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
@@ -172,6 +375,15 @@ import React
|
|
|
172
375
|
return
|
|
173
376
|
}
|
|
174
377
|
|
|
378
|
+
guard manager.isCallKitRegistered(uuid) else {
|
|
379
|
+
reject(
|
|
380
|
+
"UNKNOWN_CALL",
|
|
381
|
+
"Call was not registered with CallKit or its registration was rejected",
|
|
382
|
+
nil
|
|
383
|
+
)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
175
387
|
// Mark this call as already answered to prevent duplicate emission
|
|
176
388
|
if var callData = manager.activeCalls[uuid] {
|
|
177
389
|
callData["isAlreadyAnswered"] = true
|
|
@@ -190,6 +402,22 @@ import React
|
|
|
190
402
|
}
|
|
191
403
|
}
|
|
192
404
|
|
|
405
|
+
@objc func isCallRegistered(
|
|
406
|
+
_ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
407
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
408
|
+
) {
|
|
409
|
+
guard let uuid = UUID(uuidString: callUUID) else {
|
|
410
|
+
reject("INVALID_UUID", "Invalid UUID format", nil)
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let registered = getCallKitManager().isCallKitRegistered(uuid)
|
|
415
|
+
resolve([
|
|
416
|
+
"registered": registered,
|
|
417
|
+
"callUUID": callUUID,
|
|
418
|
+
])
|
|
419
|
+
}
|
|
420
|
+
|
|
193
421
|
@objc func reportCallConnected(
|
|
194
422
|
_ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
195
423
|
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
@@ -208,34 +436,35 @@ import React
|
|
|
208
436
|
provider.reportOutgoingCall(with: uuid, connectedAt: Date())
|
|
209
437
|
|
|
210
438
|
// Fulfill deferred CXAnswerCallAction now that peer connection is ready
|
|
211
|
-
if let pendingAction = manager.
|
|
439
|
+
if let pendingAction = manager.pendingAnswerActions.removeValue(forKey: uuid) {
|
|
212
440
|
NSLog("TelnyxVoice: reportCallConnected - fulfilling deferred CXAnswerCallAction")
|
|
213
441
|
pendingAction.fulfill()
|
|
214
|
-
manager.pendingAnswerAction = nil
|
|
215
442
|
} else {
|
|
216
|
-
|
|
217
|
-
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
218
|
-
rtcAudioSession.lockForConfiguration()
|
|
219
|
-
let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
|
|
220
|
-
webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
|
|
221
|
-
do {
|
|
222
|
-
try rtcAudioSession.setConfiguration(webRTCConfig)
|
|
223
|
-
} catch {
|
|
224
|
-
NSLog("TelnyxVoice: reportCallConnected - setConfiguration error: \(error)")
|
|
225
|
-
}
|
|
226
|
-
do {
|
|
227
|
-
try rtcAudioSession.setActive(true)
|
|
228
|
-
} catch {
|
|
229
|
-
NSLog("TelnyxVoice: reportCallConnected - setActive error: \(error)")
|
|
230
|
-
}
|
|
231
|
-
rtcAudioSession.isAudioEnabled = true
|
|
232
|
-
rtcAudioSession.unlockForConfiguration()
|
|
233
|
-
rtcAudioSession.audioSessionDidActivate(AVAudioSession.sharedInstance())
|
|
443
|
+
manager.verifyAudioAfterConnection(for: uuid)
|
|
234
444
|
}
|
|
235
445
|
|
|
236
446
|
resolve(["success": true])
|
|
237
447
|
}
|
|
238
448
|
|
|
449
|
+
#if DEBUG
|
|
450
|
+
@objc func simulateAudioSetupRace(
|
|
451
|
+
_ callUUID: String,
|
|
452
|
+
delayMilliseconds: NSNumber,
|
|
453
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
454
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
455
|
+
) {
|
|
456
|
+
guard let uuid = UUID(uuidString: callUUID) else {
|
|
457
|
+
reject("INVALID_UUID", "Invalid UUID format", nil)
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
getCallKitManager().simulateAudioSetupRace(
|
|
461
|
+
for: uuid,
|
|
462
|
+
delay: delayMilliseconds.doubleValue / 1_000
|
|
463
|
+
)
|
|
464
|
+
resolve(["scheduled": true, "delayMilliseconds": delayMilliseconds])
|
|
465
|
+
}
|
|
466
|
+
#endif
|
|
467
|
+
|
|
239
468
|
@objc func reportCallEnded(
|
|
240
469
|
_ callUUID: String, reason: NSNumber,
|
|
241
470
|
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
@@ -254,6 +483,9 @@ import React
|
|
|
254
483
|
|
|
255
484
|
let endReason = CXCallEndedReason(rawValue: reason.intValue) ?? .remoteEnded
|
|
256
485
|
provider.reportCall(with: uuid, endedAt: Date(), reason: endReason)
|
|
486
|
+
manager.activeCalls.removeValue(forKey: uuid)
|
|
487
|
+
manager.pendingAnswerActions.removeValue(forKey: uuid)?.fail()
|
|
488
|
+
manager.pendingHeldActions.removeValue(forKey: uuid)?.fail()
|
|
257
489
|
resolve(["success": true])
|
|
258
490
|
}
|
|
259
491
|
|
|
@@ -286,6 +518,7 @@ import React
|
|
|
286
518
|
let callUpdate = CXCallUpdate()
|
|
287
519
|
callUpdate.remoteHandle = callHandle
|
|
288
520
|
callUpdate.localizedCallerName = displayName
|
|
521
|
+
callUpdate.supportsHolding = true
|
|
289
522
|
|
|
290
523
|
provider.reportCall(with: uuid, updated: callUpdate)
|
|
291
524
|
resolve(["success": true])
|
|
@@ -507,8 +740,47 @@ import React
|
|
|
507
740
|
public var voipRegistry: PKPushRegistry?
|
|
508
741
|
public var callKitProvider: CXProvider?
|
|
509
742
|
public var callKitController: CXCallController?
|
|
510
|
-
|
|
511
|
-
public var
|
|
743
|
+
private let stateLock = NSRecursiveLock()
|
|
744
|
+
public lazy var activeCalls = LockedDictionary<UUID, [String: Any]>(lock: stateLock)
|
|
745
|
+
public lazy var pendingAnswerActions = LockedDictionary<UUID, CXAnswerCallAction>(
|
|
746
|
+
lock: stateLock)
|
|
747
|
+
public lazy var pendingHeldActions = LockedDictionary<UUID, CXSetHeldCallAction>(
|
|
748
|
+
lock: stateLock)
|
|
749
|
+
private var audioLifecycleGeneration = 0
|
|
750
|
+
|
|
751
|
+
private func advanceAudioLifecycleGeneration() -> Int {
|
|
752
|
+
stateLock.lock()
|
|
753
|
+
defer { stateLock.unlock() }
|
|
754
|
+
audioLifecycleGeneration += 1
|
|
755
|
+
return audioLifecycleGeneration
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private func currentAudioLifecycleGeneration() -> Int {
|
|
759
|
+
stateLock.lock()
|
|
760
|
+
defer { stateLock.unlock() }
|
|
761
|
+
return audioLifecycleGeneration
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
fileprivate func markCallKitRegistered(_ callUUID: UUID) {
|
|
765
|
+
guard var callData = activeCalls[callUUID] else {
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
callData["isCallKitRegistered"] = true
|
|
770
|
+
activeCalls[callUUID] = callData
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
fileprivate func isCallKitRegistered(_ callUUID: UUID) -> Bool {
|
|
774
|
+
guard let callData = activeCalls[callUUID] else {
|
|
775
|
+
return false
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if callData["direction"] as? String == "incoming" {
|
|
779
|
+
return callData["isCallKitRegistered"] as? Bool == true
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return true
|
|
783
|
+
}
|
|
512
784
|
|
|
513
785
|
private override init() {
|
|
514
786
|
super.init()
|
|
@@ -529,18 +801,27 @@ import React
|
|
|
529
801
|
}
|
|
530
802
|
}
|
|
531
803
|
|
|
532
|
-
|
|
804
|
+
@discardableResult
|
|
805
|
+
public func setupSynchronously() -> CXProvider {
|
|
533
806
|
// Only initialize once
|
|
534
|
-
|
|
807
|
+
if let callKitProvider = callKitProvider {
|
|
535
808
|
NSLog("TelnyxVoice: CallKit already setup, skipping")
|
|
536
|
-
return
|
|
809
|
+
return callKitProvider
|
|
537
810
|
}
|
|
538
811
|
|
|
539
812
|
// CRITICAL: Setup CallKit synchronously for terminated app VoIP push handling
|
|
540
813
|
// This MUST happen in the same run loop as the VoIP push
|
|
541
814
|
NSLog("TelnyxVoice: Synchronous CallKit setup for terminated app scenario...")
|
|
542
815
|
setupCallKit()
|
|
816
|
+
if let callKitProvider = callKitProvider {
|
|
817
|
+
NSLog("TelnyxVoice: ✅ CallKit provider ready for terminated app handling")
|
|
818
|
+
return callKitProvider
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
NSLog("TelnyxVoice: CallKit setup did not create a provider; creating fallback provider")
|
|
822
|
+
let callKitProvider = installCallKitProvider()
|
|
543
823
|
NSLog("TelnyxVoice: ✅ CallKit provider ready for terminated app handling")
|
|
824
|
+
return callKitProvider
|
|
544
825
|
}
|
|
545
826
|
|
|
546
827
|
private func setupAutomatically() {
|
|
@@ -560,10 +841,6 @@ import React
|
|
|
560
841
|
}
|
|
561
842
|
|
|
562
843
|
private func setupCallKit() {
|
|
563
|
-
// ALWAYS configure WebRTC audio, even if provider already exists
|
|
564
|
-
RTCAudioSession.sharedInstance().useManualAudio = true
|
|
565
|
-
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
|
566
|
-
|
|
567
844
|
// Guard against duplicate provider creation (async setupAutomatically can overwrite
|
|
568
845
|
// the provider created by setupSynchronously for VoIP push)
|
|
569
846
|
guard callKitProvider == nil else {
|
|
@@ -571,6 +848,85 @@ import React
|
|
|
571
848
|
return
|
|
572
849
|
}
|
|
573
850
|
|
|
851
|
+
// Set the initial state only when installing the provider. Re-running setup after
|
|
852
|
+
// CallKit activation must never disable a live WebRTC audio device.
|
|
853
|
+
RTCAudioSession.sharedInstance().useManualAudio = true
|
|
854
|
+
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
|
855
|
+
|
|
856
|
+
_ = installCallKitProvider()
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
fileprivate func activateWebRTCAudio(
|
|
860
|
+
_ audioSession: AVAudioSession = AVAudioSession.sharedInstance(),
|
|
861
|
+
reason: String
|
|
862
|
+
) {
|
|
863
|
+
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
864
|
+
rtcAudioSession.lockForConfiguration()
|
|
865
|
+
let wasAudioEnabled = rtcAudioSession.isAudioEnabled
|
|
866
|
+
NSLog("TelnyxVoice: activating WebRTC audio (\(reason)); enabled=\(wasAudioEnabled)")
|
|
867
|
+
let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
|
|
868
|
+
webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
|
|
869
|
+
do {
|
|
870
|
+
try rtcAudioSession.setConfiguration(webRTCConfig)
|
|
871
|
+
} catch {
|
|
872
|
+
NSLog("TelnyxVoice: activateWebRTCAudio configuration (\(reason)) error: \(error)")
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
var activationSucceeded = false
|
|
876
|
+
do {
|
|
877
|
+
try rtcAudioSession.setActive(true)
|
|
878
|
+
activationSucceeded = true
|
|
879
|
+
} catch {
|
|
880
|
+
NSLog("TelnyxVoice: activateWebRTCAudio activation (\(reason)) error: \(error)")
|
|
881
|
+
}
|
|
882
|
+
if activationSucceeded {
|
|
883
|
+
rtcAudioSession.isAudioEnabled = true
|
|
884
|
+
} else if !wasAudioEnabled {
|
|
885
|
+
rtcAudioSession.isAudioEnabled = false
|
|
886
|
+
}
|
|
887
|
+
rtcAudioSession.unlockForConfiguration()
|
|
888
|
+
if activationSucceeded && !wasAudioEnabled {
|
|
889
|
+
rtcAudioSession.audioSessionDidActivate(audioSession)
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
fileprivate func verifyAudioAfterConnection(for callUUID: UUID) {
|
|
894
|
+
// didActivate is asynchronous after CXAnswerCallAction.fulfill(). Verify shortly
|
|
895
|
+
// afterwards so either callback ordering converges on the same idempotent state.
|
|
896
|
+
let verificationGeneration = currentAudioLifecycleGeneration()
|
|
897
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in
|
|
898
|
+
guard let self,
|
|
899
|
+
self.currentAudioLifecycleGeneration() == verificationGeneration,
|
|
900
|
+
self.activeCalls[callUUID] != nil
|
|
901
|
+
else { return }
|
|
902
|
+
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
903
|
+
rtcAudioSession.lockForConfiguration()
|
|
904
|
+
let isAudioEnabled = rtcAudioSession.isAudioEnabled
|
|
905
|
+
rtcAudioSession.unlockForConfiguration()
|
|
906
|
+
if isAudioEnabled {
|
|
907
|
+
NSLog("TelnyxVoice: audio verification passed for \(callUUID)")
|
|
908
|
+
} else if AVAudioSession.sharedInstance().category != .playAndRecord {
|
|
909
|
+
NSLog("TelnyxVoice: audio verification skipped for inactive CallKit session \(callUUID)")
|
|
910
|
+
} else {
|
|
911
|
+
NSLog("TelnyxVoice: audio verification recovering disabled audio for \(callUUID)")
|
|
912
|
+
self.activateWebRTCAudio(reason: "connected-call verification")
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
#if DEBUG
|
|
918
|
+
fileprivate func simulateAudioSetupRace(for callUUID: UUID, delay: TimeInterval) {
|
|
919
|
+
NSLog("TelnyxVoice: [VSUP-226] scheduling late audio reset in \(delay)s")
|
|
920
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
921
|
+
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
922
|
+
rtcAudioSession.isAudioEnabled = false
|
|
923
|
+
NSLog("TelnyxVoice: [VSUP-226] injected late setup reset; enabled=false")
|
|
924
|
+
self?.verifyAudioAfterConnection(for: callUUID)
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
#endif
|
|
928
|
+
|
|
929
|
+
private func installCallKitProvider() -> CXProvider {
|
|
574
930
|
// Use the localizedName from the app's bundle display name or fallback
|
|
575
931
|
let appName =
|
|
576
932
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
|
@@ -579,19 +935,66 @@ import React
|
|
|
579
935
|
|
|
580
936
|
let configuration = CXProviderConfiguration(localizedName: appName)
|
|
581
937
|
configuration.supportsVideo = false
|
|
582
|
-
configuration.maximumCallGroups =
|
|
938
|
+
configuration.maximumCallGroups = 2
|
|
583
939
|
configuration.maximumCallsPerCallGroup = 1
|
|
584
940
|
configuration.supportedHandleTypes = [.phoneNumber, .generic]
|
|
585
941
|
configuration.includesCallsInRecents = true
|
|
586
942
|
|
|
587
|
-
|
|
588
|
-
|
|
943
|
+
let provider = CXProvider(configuration: configuration)
|
|
944
|
+
provider.setDelegate(self, queue: nil)
|
|
945
|
+
callKitProvider = provider
|
|
589
946
|
callKitController = CXCallController()
|
|
590
947
|
|
|
591
948
|
NSLog("TelnyxVoice: CallKit provider and controller created")
|
|
949
|
+
return provider
|
|
592
950
|
}
|
|
593
951
|
|
|
594
|
-
|
|
952
|
+
fileprivate func reportAndEndWatchdogCall(
|
|
953
|
+
callUUID: UUID,
|
|
954
|
+
callerName: String,
|
|
955
|
+
callerNumber: String,
|
|
956
|
+
payload: [AnyHashable: Any],
|
|
957
|
+
source: String,
|
|
958
|
+
endedReason: CXCallEndedReason,
|
|
959
|
+
completion: (() -> Void)? = nil
|
|
960
|
+
) {
|
|
961
|
+
let callKitProvider = setupSynchronously()
|
|
962
|
+
|
|
963
|
+
activeCalls[callUUID] = [
|
|
964
|
+
"caller": callerName,
|
|
965
|
+
"handle": callerNumber,
|
|
966
|
+
"payload": payload,
|
|
967
|
+
"uuid": callUUID.uuidString,
|
|
968
|
+
"direction": "incoming",
|
|
969
|
+
"source": source,
|
|
970
|
+
"watchdogPlaceholder": true,
|
|
971
|
+
]
|
|
972
|
+
|
|
973
|
+
let handle = CXHandle(type: .phoneNumber, value: callerNumber)
|
|
974
|
+
let callUpdate = CXCallUpdate()
|
|
975
|
+
callUpdate.remoteHandle = handle
|
|
976
|
+
callUpdate.hasVideo = false
|
|
977
|
+
callUpdate.localizedCallerName = callerName
|
|
978
|
+
callUpdate.supportsHolding = true
|
|
979
|
+
|
|
980
|
+
callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
|
|
981
|
+
if let error = error {
|
|
982
|
+
NSLog(
|
|
983
|
+
"TelnyxVoice: Watchdog CallKit report failed for \(source): \(error.localizedDescription)"
|
|
984
|
+
)
|
|
985
|
+
} else {
|
|
986
|
+
NSLog("TelnyxVoice: Watchdog CallKit call reported for \(source)")
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: endedReason)
|
|
990
|
+
self.activeCalls.removeValue(forKey: callUUID)
|
|
991
|
+
|
|
992
|
+
self.pendingAnswerActions.removeValue(forKey: callUUID)?.fail()
|
|
993
|
+
self.pendingHeldActions.removeValue(forKey: callUUID)?.fail()
|
|
994
|
+
|
|
995
|
+
completion?()
|
|
996
|
+
}
|
|
997
|
+
}
|
|
595
998
|
|
|
596
999
|
private func observeAppDelegate() {
|
|
597
1000
|
// Automatically hook into app lifecycle if needed
|
|
@@ -620,28 +1023,34 @@ import React
|
|
|
620
1023
|
}
|
|
621
1024
|
|
|
622
1025
|
NSLog("TelnyxVoice: Missed call VoIP push received: \(payload)")
|
|
623
|
-
setupSynchronously()
|
|
1026
|
+
let callKitProvider = setupSynchronously()
|
|
624
1027
|
|
|
625
1028
|
let callId = TelnyxMissedCallPush.callId(from: payload)
|
|
626
1029
|
let missedCallId = TelnyxMissedCallPush.stableIdentifier(from: payload)
|
|
627
|
-
if hasProcessedMissedCall(id: missedCallId) {
|
|
628
|
-
NSLog("TelnyxVoice: Ignoring duplicate missed call push: \(missedCallId)")
|
|
629
|
-
completion?()
|
|
630
|
-
return true
|
|
631
|
-
}
|
|
632
|
-
|
|
633
1030
|
let callUUID = TelnyxMissedCallPush.uuid(from: missedCallId)
|
|
634
1031
|
let callerName = TelnyxMissedCallPush.callerName(from: payload)
|
|
635
1032
|
let callerNumber = TelnyxMissedCallPush.callerNumber(from: payload)
|
|
636
1033
|
|
|
1034
|
+
if hasProcessedMissedCall(id: missedCallId) {
|
|
1035
|
+
NSLog("TelnyxVoice: Suppressing duplicate missed call notification: \(missedCallId)")
|
|
1036
|
+
reportAndEndWatchdogCall(
|
|
1037
|
+
callUUID: UUID(),
|
|
1038
|
+
callerName: callerName,
|
|
1039
|
+
callerNumber: callerNumber,
|
|
1040
|
+
payload: payload,
|
|
1041
|
+
source: "missed_call_duplicate_watchdog",
|
|
1042
|
+
endedReason: .remoteEnded,
|
|
1043
|
+
completion: completion
|
|
1044
|
+
)
|
|
1045
|
+
return true
|
|
1046
|
+
}
|
|
1047
|
+
|
|
637
1048
|
let finishMissedCall = {
|
|
638
|
-
|
|
1049
|
+
callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
|
|
639
1050
|
self.activeCalls.removeValue(forKey: callUUID)
|
|
640
1051
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
self.pendingAnswerAction = nil
|
|
644
|
-
}
|
|
1052
|
+
self.pendingAnswerActions.removeValue(forKey: callUUID)?.fail()
|
|
1053
|
+
self.pendingHeldActions.removeValue(forKey: callUUID)?.fail()
|
|
645
1054
|
|
|
646
1055
|
NSLog("TelnyxVoice: Ended stale CallKit call for missed call UUID: \(callUUID)")
|
|
647
1056
|
self.clearPendingPushData(for: callId)
|
|
@@ -656,13 +1065,30 @@ import React
|
|
|
656
1065
|
|
|
657
1066
|
if let activeCall = activeCalls[callUUID] {
|
|
658
1067
|
guard activeCall["source"] as? String == "missed_call_push" else {
|
|
659
|
-
NSLog("TelnyxVoice:
|
|
1068
|
+
NSLog("TelnyxVoice: Suppressing missed call notification because UUID belongs to an active call: \(callUUID)")
|
|
660
1069
|
markMissedCallProcessed(id: missedCallId)
|
|
661
|
-
|
|
1070
|
+
reportAndEndWatchdogCall(
|
|
1071
|
+
callUUID: UUID(),
|
|
1072
|
+
callerName: callerName,
|
|
1073
|
+
callerNumber: callerNumber,
|
|
1074
|
+
payload: payload,
|
|
1075
|
+
source: "missed_call_collision_watchdog",
|
|
1076
|
+
endedReason: .remoteEnded,
|
|
1077
|
+
completion: completion
|
|
1078
|
+
)
|
|
662
1079
|
return true
|
|
663
1080
|
}
|
|
664
1081
|
|
|
665
|
-
|
|
1082
|
+
reportAndEndWatchdogCall(
|
|
1083
|
+
callUUID: UUID(),
|
|
1084
|
+
callerName: callerName,
|
|
1085
|
+
callerNumber: callerNumber,
|
|
1086
|
+
payload: payload,
|
|
1087
|
+
source: "missed_call_active_watchdog",
|
|
1088
|
+
endedReason: .remoteEnded
|
|
1089
|
+
) {
|
|
1090
|
+
finishMissedCall()
|
|
1091
|
+
}
|
|
666
1092
|
return true
|
|
667
1093
|
}
|
|
668
1094
|
|
|
@@ -680,10 +1106,12 @@ import React
|
|
|
680
1106
|
callUpdate.remoteHandle = handle
|
|
681
1107
|
callUpdate.hasVideo = false
|
|
682
1108
|
callUpdate.localizedCallerName = callerName
|
|
1109
|
+
callUpdate.supportsHolding = true
|
|
683
1110
|
|
|
684
|
-
callKitProvider
|
|
1111
|
+
callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
|
|
685
1112
|
if let error = error {
|
|
686
1113
|
NSLog("TelnyxVoice: Missed call CallKit report failed: \(error.localizedDescription)")
|
|
1114
|
+
callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .failed)
|
|
687
1115
|
self.activeCalls.removeValue(forKey: callUUID)
|
|
688
1116
|
self.clearPendingPushData(for: callId)
|
|
689
1117
|
completion?()
|
|
@@ -696,7 +1124,7 @@ import React
|
|
|
696
1124
|
return true
|
|
697
1125
|
}
|
|
698
1126
|
|
|
699
|
-
|
|
1127
|
+
fileprivate func clearPendingPushData(for callId: String?) {
|
|
700
1128
|
if hasPendingPushData(forDifferentCallThan: callId) {
|
|
701
1129
|
NSLog("TelnyxVoice: Skipping pending push cleanup for missed call; pending data belongs to a different call")
|
|
702
1130
|
return
|
|
@@ -721,7 +1149,9 @@ import React
|
|
|
721
1149
|
UserDefaults.standard.string(forKey: "@pending_callkit_uuid"),
|
|
722
1150
|
].compactMap { $0 }
|
|
723
1151
|
|
|
724
|
-
return pendingIds.contains {
|
|
1152
|
+
return pendingIds.contains {
|
|
1153
|
+
!$0.isEmpty && $0.caseInsensitiveCompare(missedCallId) != .orderedSame
|
|
1154
|
+
}
|
|
725
1155
|
}
|
|
726
1156
|
|
|
727
1157
|
private func pendingCallId(fromJsonForKey key: String) -> String? {
|
|
@@ -918,6 +1348,7 @@ import React
|
|
|
918
1348
|
"uuid": callUUID.uuidString,
|
|
919
1349
|
"direction": "incoming",
|
|
920
1350
|
"source": "push",
|
|
1351
|
+
"isCallKitRegistered": false,
|
|
921
1352
|
]
|
|
922
1353
|
|
|
923
1354
|
let handle = CXHandle(type: .phoneNumber, value: caller)
|
|
@@ -925,6 +1356,7 @@ import React
|
|
|
925
1356
|
callUpdate.remoteHandle = handle
|
|
926
1357
|
callUpdate.hasVideo = false
|
|
927
1358
|
callUpdate.localizedCallerName = caller
|
|
1359
|
+
callUpdate.supportsHolding = true
|
|
928
1360
|
|
|
929
1361
|
callKitProvider?.reportNewIncomingCall(with: callUUID, update: callUpdate) {
|
|
930
1362
|
[weak self] error in
|
|
@@ -932,6 +1364,7 @@ import React
|
|
|
932
1364
|
NSLog("TelnyxVoice: CallKit error: \(error.localizedDescription)")
|
|
933
1365
|
self?.activeCalls.removeValue(forKey: callUUID)
|
|
934
1366
|
} else {
|
|
1367
|
+
self?.markCallKitRegistered(callUUID)
|
|
935
1368
|
NSLog("TelnyxVoice: CallKit incoming call reported successfully")
|
|
936
1369
|
}
|
|
937
1370
|
completion()
|
|
@@ -946,12 +1379,35 @@ import React
|
|
|
946
1379
|
NSLog("📞 TelnyxVoice: CALLKIT PROVIDER RESET - Provider: \(provider)")
|
|
947
1380
|
NSLog("TelnyxVoice: CallKit provider reset - ending all active calls")
|
|
948
1381
|
activeCalls.removeAll()
|
|
1382
|
+
pendingAnswerActions.values.forEach { $0.fail() }
|
|
1383
|
+
pendingAnswerActions.removeAll()
|
|
1384
|
+
pendingHeldActions.values.forEach { $0.fail() }
|
|
1385
|
+
pendingHeldActions.removeAll()
|
|
949
1386
|
}
|
|
950
1387
|
|
|
951
1388
|
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
|
952
1389
|
NSLog("📞 TelnyxVoice: CALLKIT ANSWER ACTION - Provider: \(provider), Action: \(action)")
|
|
953
1390
|
NSLog("TelnyxVoice: User answered call with UUID: \(action.callUUID)")
|
|
954
1391
|
|
|
1392
|
+
if activeCalls[action.callUUID]?["watchdogPlaceholder"] as? Bool == true {
|
|
1393
|
+
NSLog("TelnyxVoice: Failing answer for watchdog placeholder call: \(action.callUUID)")
|
|
1394
|
+
activeCalls.removeValue(forKey: action.callUUID)
|
|
1395
|
+
action.fail()
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
guard isCallKitRegistered(action.callUUID) else {
|
|
1400
|
+
NSLog("TelnyxVoice: Failing answer for unknown UUID: \(action.callUUID)")
|
|
1401
|
+
action.fail()
|
|
1402
|
+
return
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
guard pendingAnswerActions[action.callUUID] == nil else {
|
|
1406
|
+
NSLog("TelnyxVoice: Failing duplicate answer for UUID: \(action.callUUID)")
|
|
1407
|
+
action.fail()
|
|
1408
|
+
return
|
|
1409
|
+
}
|
|
1410
|
+
|
|
955
1411
|
// Check if this is a programmatic answer (call already answered in WebRTC)
|
|
956
1412
|
// vs a user answer from CallKit UI
|
|
957
1413
|
if let callData = activeCalls[action.callUUID],
|
|
@@ -961,25 +1417,84 @@ import React
|
|
|
961
1417
|
NSLog("TelnyxVoice: Call already answered in WebRTC, skipping emission")
|
|
962
1418
|
} else {
|
|
963
1419
|
// Notify React Native via CallKit bridge (only for user-initiated answers)
|
|
964
|
-
CallKitBridge.shared?.emitCallEvent(
|
|
1420
|
+
let eventDelivered = CallKitBridge.shared?.emitCallEvent(
|
|
965
1421
|
"CallKitDidPerformAnswerCallAction", callUUID: action.callUUID,
|
|
966
|
-
callData: activeCalls[action.callUUID])
|
|
1422
|
+
callData: activeCalls[action.callUUID]) ?? false
|
|
1423
|
+
if !eventDelivered {
|
|
1424
|
+
UserDefaults.standard.set(action.callUUID.uuidString, forKey: "pending_callkit_answer")
|
|
1425
|
+
UserDefaults.standard.synchronize()
|
|
1426
|
+
NSLog("TelnyxVoice: Stored pending CallKit answer for UUID: \(action.callUUID)")
|
|
1427
|
+
}
|
|
967
1428
|
}
|
|
968
1429
|
|
|
969
1430
|
// Defer action.fulfill() until reportCallConnected when peer connection is ready
|
|
970
1431
|
NSLog("TelnyxVoice: Deferring CXAnswerCallAction.fulfill() until peer connection is ready")
|
|
971
|
-
self.
|
|
1432
|
+
self.pendingAnswerActions[action.callUUID] = action
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
|
|
1436
|
+
NSLog(
|
|
1437
|
+
"TelnyxVoice: User requested held=\(action.isOnHold) for UUID: \(action.callUUID)"
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
guard activeCalls[action.callUUID] != nil else {
|
|
1441
|
+
NSLog("TelnyxVoice: Failing held action for unknown UUID: \(action.callUUID)")
|
|
1442
|
+
action.fail()
|
|
1443
|
+
return
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
guard pendingHeldActions[action.callUUID] == nil else {
|
|
1447
|
+
NSLog("TelnyxVoice: Failing duplicate held action for UUID: \(action.callUUID)")
|
|
1448
|
+
action.fail()
|
|
1449
|
+
return
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
pendingHeldActions[action.callUUID] = action
|
|
1453
|
+
let emitted = CallKitBridge.shared?.emitHeldCallEvent(
|
|
1454
|
+
callUUID: action.callUUID,
|
|
1455
|
+
isOnHold: action.isOnHold,
|
|
1456
|
+
callData: activeCalls[action.callUUID]
|
|
1457
|
+
) ?? false
|
|
1458
|
+
|
|
1459
|
+
guard emitted else {
|
|
1460
|
+
pendingHeldActions.removeValue(forKey: action.callUUID)
|
|
1461
|
+
action.fail()
|
|
1462
|
+
return
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak action] in
|
|
1466
|
+
guard let self = self, let action = action,
|
|
1467
|
+
self.pendingHeldActions[action.callUUID] === action
|
|
1468
|
+
else { return }
|
|
1469
|
+
|
|
1470
|
+
NSLog("TelnyxVoice: Held action timed out for UUID: \(action.callUUID)")
|
|
1471
|
+
self.pendingHeldActions.removeValue(forKey: action.callUUID)
|
|
1472
|
+
action.fail()
|
|
1473
|
+
}
|
|
972
1474
|
}
|
|
973
1475
|
|
|
974
1476
|
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
|
975
1477
|
NSLog("TelnyxVoice: User ended call with UUID: \(action.callUUID)")
|
|
976
1478
|
|
|
1479
|
+
guard let callData = activeCalls[action.callUUID] else {
|
|
1480
|
+
NSLog("TelnyxVoice: Failing end action for unknown UUID: \(action.callUUID)")
|
|
1481
|
+
action.fail()
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
if UserDefaults.standard.string(forKey: "pending_callkit_answer") == action.callUUID.uuidString {
|
|
1485
|
+
UserDefaults.standard.removeObject(forKey: "pending_callkit_answer")
|
|
1486
|
+
UserDefaults.standard.synchronize()
|
|
1487
|
+
}
|
|
1488
|
+
|
|
977
1489
|
// Notify React Native via CallKit bridge
|
|
978
1490
|
CallKitBridge.shared?.emitCallEvent(
|
|
979
1491
|
"CallKitDidPerformEndCallAction", callUUID: action.callUUID,
|
|
980
|
-
callData:
|
|
1492
|
+
callData: callData)
|
|
981
1493
|
|
|
1494
|
+
_ = advanceAudioLifecycleGeneration()
|
|
982
1495
|
activeCalls.removeValue(forKey: action.callUUID)
|
|
1496
|
+
pendingAnswerActions.removeValue(forKey: action.callUUID)?.fail()
|
|
1497
|
+
pendingHeldActions.removeValue(forKey: action.callUUID)?.fail()
|
|
983
1498
|
NSLog("📞 TelnyxVoice: Fulfilling CXEndCallAction for call UUID: \(action.callUUID)")
|
|
984
1499
|
action.fulfill()
|
|
985
1500
|
NSLog("📞 TelnyxVoice: ✅ CXEndCallAction fulfilled successfully")
|
|
@@ -993,38 +1508,30 @@ import React
|
|
|
993
1508
|
"CallKitDidReceiveStartCallAction", callUUID: action.callUUID,
|
|
994
1509
|
callData: activeCalls[action.callUUID])
|
|
995
1510
|
|
|
1511
|
+
let callUpdate = CXCallUpdate()
|
|
1512
|
+
callUpdate.remoteHandle = action.handle
|
|
1513
|
+
callUpdate.hasVideo = false
|
|
1514
|
+
callUpdate.supportsHolding = true
|
|
1515
|
+
provider.reportCall(with: action.callUUID, updated: callUpdate)
|
|
1516
|
+
|
|
996
1517
|
NSLog("📞 TelnyxVoice: Fulfilling CXStartCallAction for call UUID: \(action.callUUID)")
|
|
997
1518
|
action.fulfill()
|
|
998
1519
|
NSLog("📞 TelnyxVoice: ✅ CXStartCallAction fulfilled successfully")
|
|
999
1520
|
}
|
|
1000
1521
|
|
|
1001
|
-
public func provider(_ provider: CXProvider,
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
let
|
|
1005
|
-
|
|
1006
|
-
// Step 1: Configure (matches native iOS SDK setupCorrectAudioConfiguration)
|
|
1007
|
-
rtcAudioSession.lockForConfiguration()
|
|
1008
|
-
let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
|
|
1009
|
-
webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
|
|
1010
|
-
do {
|
|
1011
|
-
try rtcAudioSession.setConfiguration(webRTCConfig)
|
|
1012
|
-
} catch {
|
|
1013
|
-
NSLog("TelnyxVoice: didActivateAudioSession - setConfiguration error: \(error)")
|
|
1522
|
+
public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
|
|
1523
|
+
if let answerAction = action as? CXAnswerCallAction {
|
|
1524
|
+
pendingAnswerActions.removeValue(forKey: answerAction.callUUID)
|
|
1525
|
+
} else if let heldAction = action as? CXSetHeldCallAction {
|
|
1526
|
+
pendingHeldActions.removeValue(forKey: heldAction.callUUID)
|
|
1014
1527
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
// Step 2: Activate (matches native iOS SDK setAudioSessionActive)
|
|
1018
|
-
rtcAudioSession.lockForConfiguration()
|
|
1019
|
-
do {
|
|
1020
|
-
try rtcAudioSession.setActive(true)
|
|
1021
|
-
} catch {
|
|
1022
|
-
NSLog("TelnyxVoice: didActivateAudioSession - setActive error: \(error)")
|
|
1023
|
-
}
|
|
1024
|
-
rtcAudioSession.isAudioEnabled = true
|
|
1025
|
-
rtcAudioSession.unlockForConfiguration()
|
|
1528
|
+
action.fail()
|
|
1529
|
+
}
|
|
1026
1530
|
|
|
1027
|
-
|
|
1531
|
+
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
|
1532
|
+
NSLog("TelnyxVoice: Audio session activated by CallKit")
|
|
1533
|
+
_ = advanceAudioLifecycleGeneration()
|
|
1534
|
+
activateWebRTCAudio(audioSession, reason: "CallKit didActivate")
|
|
1028
1535
|
|
|
1029
1536
|
// Emit audio session activated event to React Native
|
|
1030
1537
|
CallKitBridge.shared?.emitAudioSessionEvent(
|
|
@@ -1038,6 +1545,7 @@ import React
|
|
|
1038
1545
|
|
|
1039
1546
|
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
|
1040
1547
|
NSLog("TelnyxVoice: Audio session deactivated by CallKit")
|
|
1548
|
+
_ = advanceAudioLifecycleGeneration()
|
|
1041
1549
|
|
|
1042
1550
|
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
1043
1551
|
|
|
@@ -1084,6 +1592,33 @@ import React
|
|
|
1084
1592
|
super.init()
|
|
1085
1593
|
}
|
|
1086
1594
|
|
|
1595
|
+
private func storePendingVoipPush(_ payload: [AnyHashable: Any]) {
|
|
1596
|
+
do {
|
|
1597
|
+
let jsonData = try JSONSerialization.data(withJSONObject: payload)
|
|
1598
|
+
let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
|
|
1599
|
+
|
|
1600
|
+
UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
|
|
1601
|
+
UserDefaults.standard.set(jsonString, forKey: "pending_push_metadata")
|
|
1602
|
+
|
|
1603
|
+
let voipPushData = [
|
|
1604
|
+
"payload": payload
|
|
1605
|
+
]
|
|
1606
|
+
let voipJsonData = try JSONSerialization.data(withJSONObject: voipPushData)
|
|
1607
|
+
let voipJsonString = String(data: voipJsonData, encoding: .utf8) ?? ""
|
|
1608
|
+
UserDefaults.standard.set(voipJsonString, forKey: "pending_voip_push")
|
|
1609
|
+
UserDefaults.standard.synchronize()
|
|
1610
|
+
NSLog(
|
|
1611
|
+
"[TelnyxVoipPushHandler] Stored VoIP push data after CallKit registration succeeded"
|
|
1612
|
+
)
|
|
1613
|
+
} catch {
|
|
1614
|
+
NSLog("[TelnyxVoipPushHandler] Error converting VoIP payload to JSON: \(error)")
|
|
1615
|
+
UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
|
|
1616
|
+
UserDefaults.standard.set("{}", forKey: "pending_push_metadata")
|
|
1617
|
+
UserDefaults.standard.set("{\"payload\":{}}", forKey: "pending_voip_push")
|
|
1618
|
+
UserDefaults.standard.synchronize()
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1087
1622
|
/**
|
|
1088
1623
|
* Initialize VoIP push registration
|
|
1089
1624
|
* Call this from the main app's AppDelegate didFinishLaunchingWithOptions
|
|
@@ -1132,35 +1667,6 @@ import React
|
|
|
1132
1667
|
} catch {
|
|
1133
1668
|
NSLog("Failed to activate audio session: \(error)")
|
|
1134
1669
|
}
|
|
1135
|
-
// Store the VoIP push data for VoicePnBridge
|
|
1136
|
-
do {
|
|
1137
|
-
let jsonData = try JSONSerialization.data(withJSONObject: payload.dictionaryPayload)
|
|
1138
|
-
let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
|
|
1139
|
-
|
|
1140
|
-
// Store for TelnyxVoiceApp (push action flow)
|
|
1141
|
-
UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
|
|
1142
|
-
UserDefaults.standard.set(jsonString, forKey: "pending_push_metadata")
|
|
1143
|
-
|
|
1144
|
-
// ALSO store for VoicePnBridge.getPendingVoipPush() (CallKit coordinator flow)
|
|
1145
|
-
// This creates a structured object with payload property
|
|
1146
|
-
let voipPushData = [
|
|
1147
|
-
"payload": payload.dictionaryPayload
|
|
1148
|
-
]
|
|
1149
|
-
let voipJsonData = try JSONSerialization.data(withJSONObject: voipPushData)
|
|
1150
|
-
let voipJsonString = String(data: voipJsonData, encoding: .utf8) ?? ""
|
|
1151
|
-
UserDefaults.standard.set(voipJsonString, forKey: "pending_voip_push")
|
|
1152
|
-
|
|
1153
|
-
UserDefaults.standard.synchronize()
|
|
1154
|
-
NSLog(
|
|
1155
|
-
"[TelnyxVoipPushHandler] Stored VoIP push data for both TelnyxVoiceApp and VoicePnBridge"
|
|
1156
|
-
)
|
|
1157
|
-
} catch {
|
|
1158
|
-
NSLog("[TelnyxVoipPushHandler] Error converting VoIP payload to JSON: \(error)")
|
|
1159
|
-
UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
|
|
1160
|
-
UserDefaults.standard.set("{}", forKey: "pending_push_metadata")
|
|
1161
|
-
UserDefaults.standard.set("{\"payload\":{}}", forKey: "pending_voip_push")
|
|
1162
|
-
UserDefaults.standard.synchronize()
|
|
1163
|
-
}
|
|
1164
1670
|
|
|
1165
1671
|
// Extract caller information and call_id from the payload
|
|
1166
1672
|
var callerName = "Unknown Caller"
|
|
@@ -1181,12 +1687,20 @@ import React
|
|
|
1181
1687
|
callId = payload.dictionaryPayload["call_id"] as? String
|
|
1182
1688
|
}
|
|
1183
1689
|
|
|
1184
|
-
// Use call_id as CallKit UUID to ensure matching with WebRTC
|
|
1690
|
+
// Use call_id as CallKit UUID to ensure matching with WebRTC.
|
|
1185
1691
|
guard let callIdString = callId, let callUUID = UUID(uuidString: callIdString) else {
|
|
1186
1692
|
NSLog(
|
|
1187
|
-
"[TelnyxVoipPushHandler]
|
|
1693
|
+
"[TelnyxVoipPushHandler] ⚠️ Invalid or missing call_id in payload (\(callId ?? "nil")); reporting failed watchdog placeholder"
|
|
1694
|
+
)
|
|
1695
|
+
TelnyxCallKitManager.shared.reportAndEndWatchdogCall(
|
|
1696
|
+
callUUID: UUID(),
|
|
1697
|
+
callerName: callerName,
|
|
1698
|
+
callerNumber: callerNumber,
|
|
1699
|
+
payload: payload.dictionaryPayload,
|
|
1700
|
+
source: "malformed_push_watchdog",
|
|
1701
|
+
endedReason: .failed,
|
|
1702
|
+
completion: completion
|
|
1188
1703
|
)
|
|
1189
|
-
completion()
|
|
1190
1704
|
return
|
|
1191
1705
|
}
|
|
1192
1706
|
|
|
@@ -1202,16 +1716,7 @@ import React
|
|
|
1202
1716
|
|
|
1203
1717
|
// CRITICAL: Setup CallKit SYNCHRONOUSLY - no async dispatch allowed
|
|
1204
1718
|
// This must happen in the same run loop as the VoIP push
|
|
1205
|
-
callKitManager.setupSynchronously()
|
|
1206
|
-
|
|
1207
|
-
// Ensure we have a valid CallKit provider after setup
|
|
1208
|
-
guard let callKitProvider = callKitManager.callKitProvider else {
|
|
1209
|
-
NSLog(
|
|
1210
|
-
"[TelnyxVoipPushHandler] ❌ FATAL: CallKit provider not available after synchronous setup!"
|
|
1211
|
-
)
|
|
1212
|
-
completion()
|
|
1213
|
-
return
|
|
1214
|
-
}
|
|
1719
|
+
let callKitProvider = callKitManager.setupSynchronously()
|
|
1215
1720
|
|
|
1216
1721
|
NSLog("[TelnyxVoipPushHandler] ✅ CallKit provider ready, reporting incoming call")
|
|
1217
1722
|
|
|
@@ -1225,6 +1730,7 @@ import React
|
|
|
1225
1730
|
"uuid": callUUID.uuidString,
|
|
1226
1731
|
"direction": "incoming",
|
|
1227
1732
|
"source": isAppRunning ? "push_notification" : "terminated_app_push",
|
|
1733
|
+
"isCallKitRegistered": false,
|
|
1228
1734
|
]
|
|
1229
1735
|
|
|
1230
1736
|
// Report to CallKit immediately
|
|
@@ -1233,18 +1739,27 @@ import React
|
|
|
1233
1739
|
callUpdate.remoteHandle = handle
|
|
1234
1740
|
callUpdate.hasVideo = false
|
|
1235
1741
|
callUpdate.localizedCallerName = callerName
|
|
1742
|
+
callUpdate.supportsHolding = true
|
|
1236
1743
|
|
|
1237
1744
|
callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
|
|
1238
1745
|
if let error = error {
|
|
1746
|
+
let nsError = error as NSError
|
|
1747
|
+
let reason =
|
|
1748
|
+
nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
|
|
1749
|
+
? "filteredByDoNotDisturb" : "registrationRejected"
|
|
1239
1750
|
NSLog(
|
|
1240
|
-
"[TelnyxVoipPushHandler]
|
|
1751
|
+
"[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
|
|
1241
1752
|
)
|
|
1242
1753
|
callKitManager.activeCalls.removeValue(forKey: callUUID)
|
|
1754
|
+
callKitManager.clearPendingPushData(for: callUUID.uuidString)
|
|
1243
1755
|
} else {
|
|
1244
1756
|
NSLog(
|
|
1245
1757
|
"[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
|
|
1246
1758
|
)
|
|
1247
1759
|
|
|
1760
|
+
callKitManager.markCallKitRegistered(callUUID)
|
|
1761
|
+
self.storePendingVoipPush(payload.dictionaryPayload)
|
|
1762
|
+
|
|
1248
1763
|
// CRITICAL: Store the CallKit UUID for the React Native CallKitCoordinator
|
|
1249
1764
|
// This allows the coordinator to find the existing CallKit call instead of creating a duplicate
|
|
1250
1765
|
UserDefaults.standard.set(callUUID.uuidString, forKey: "@pending_callkit_uuid")
|