@telnyx/react-voice-commons-sdk 0.4.3 → 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/TelnyxFirebaseMessagingService.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 +74 -1
- package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +36 -1
- package/ios/CallKitBridge.m +20 -1
- package/ios/CallKitBridge.swift +935 -60
- package/ios/README.md +2 -2
- package/ios/VoicePnBridge.m +21 -1
- package/ios/VoicePnBridge.swift +62 -1
- 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/index.d.ts +1 -0
- package/lib/index.js +9 -0
- package/lib/internal/CallKitHandler.d.ts +3 -0
- package/lib/internal/CallKitHandler.js +52 -46
- package/lib/internal/calls/call-state-controller.d.ts +39 -1
- package/lib/internal/calls/call-state-controller.js +152 -64
- package/lib/internal/session/session-manager.d.ts +16 -1
- package/lib/internal/session/session-manager.js +229 -58
- package/lib/internal/voice-pn-bridge.d.ts +44 -0
- package/lib/internal/voice-pn-bridge.js +94 -0
- package/lib/models/call.js +35 -0
- package/lib/models/config.d.ts +18 -0
- package/lib/models/config.js +2 -0
- package/lib/telnyx-voice-app.js +85 -43
- package/lib/telnyx-voip-client.d.ts +48 -5
- package/lib/telnyx-voip-client.js +276 -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/index.ts +3 -0
- package/src/internal/CallKitHandler.tsx +60 -55
- package/src/internal/calls/call-state-controller.ts +173 -74
- package/src/internal/session/session-manager.ts +277 -61
- package/src/internal/voice-pn-bridge.ts +111 -0
- package/src/models/call.ts +34 -0
- package/src/models/config.ts +18 -0
- package/src/telnyx-voice-app.tsx +96 -45
- package/src/telnyx-voip-client.ts +260 -24
- package/src/types/telnyx-sdk.d.ts +12 -1
package/ios/CallKitBridge.swift
CHANGED
|
@@ -6,8 +6,52 @@ import React
|
|
|
6
6
|
import AVFoundation
|
|
7
7
|
import UIKit
|
|
8
8
|
import PushKit
|
|
9
|
+
import UserNotifications
|
|
9
10
|
import WebRTC
|
|
10
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
|
+
|
|
11
55
|
@objc(CallKitBridge)
|
|
12
56
|
class CallKitBridge: RCTEventEmitter {
|
|
13
57
|
|
|
@@ -28,6 +72,7 @@ import React
|
|
|
28
72
|
"CallKitDidReceiveStartCallAction",
|
|
29
73
|
"CallKitDidPerformAnswerCallAction",
|
|
30
74
|
"CallKitDidPerformEndCallAction",
|
|
75
|
+
"CallKitDidPerformHeldCallAction",
|
|
31
76
|
"CallKitDidReceivePush",
|
|
32
77
|
"AudioSessionActivated",
|
|
33
78
|
"AudioSessionDeactivated",
|
|
@@ -49,8 +94,9 @@ import React
|
|
|
49
94
|
}
|
|
50
95
|
|
|
51
96
|
// Direct event emission methods called by TelnyxVoiceAppDelegate
|
|
52
|
-
|
|
53
|
-
|
|
97
|
+
@discardableResult
|
|
98
|
+
public func emitCallEvent(_ eventName: String, callUUID: UUID, callData: [String: Any]?) -> Bool {
|
|
99
|
+
guard hasListeners else { return false }
|
|
54
100
|
|
|
55
101
|
let eventData: [String: Any] = [
|
|
56
102
|
"callUUID": callUUID.uuidString,
|
|
@@ -58,6 +104,22 @@ import React
|
|
|
58
104
|
]
|
|
59
105
|
|
|
60
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
|
|
61
123
|
}
|
|
62
124
|
|
|
63
125
|
// Event emission method for audio session changes
|
|
@@ -89,8 +151,17 @@ import React
|
|
|
89
151
|
let startCallAction = CXStartCallAction(call: uuid, handle: callHandle)
|
|
90
152
|
let transaction = CXTransaction(action: startCallAction)
|
|
91
153
|
|
|
154
|
+
manager.activeCalls[uuid] = [
|
|
155
|
+
"caller": displayName,
|
|
156
|
+
"handle": handle,
|
|
157
|
+
"uuid": uuid.uuidString,
|
|
158
|
+
"direction": "outgoing",
|
|
159
|
+
"source": "react_native",
|
|
160
|
+
]
|
|
161
|
+
|
|
92
162
|
callController.request(transaction) { error in
|
|
93
163
|
if let error = error {
|
|
164
|
+
manager.activeCalls.removeValue(forKey: uuid)
|
|
94
165
|
reject("START_CALL_ERROR", error.localizedDescription, error)
|
|
95
166
|
} else {
|
|
96
167
|
resolve(["success": true, "callUUID": callUUID])
|
|
@@ -119,11 +190,23 @@ import React
|
|
|
119
190
|
callUpdate.remoteHandle = callHandle
|
|
120
191
|
callUpdate.hasVideo = false
|
|
121
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
|
+
]
|
|
122
203
|
|
|
123
204
|
provider.reportNewIncomingCall(with: uuid, update: callUpdate) { error in
|
|
124
205
|
if let error = error {
|
|
206
|
+
manager.activeCalls.removeValue(forKey: uuid)
|
|
125
207
|
reject("INCOMING_CALL_ERROR", error.localizedDescription, error)
|
|
126
208
|
} else {
|
|
209
|
+
manager.markCallKitRegistered(uuid)
|
|
127
210
|
resolve(["success": true, "callUUID": callUUID])
|
|
128
211
|
}
|
|
129
212
|
}
|
|
@@ -156,6 +239,127 @@ import React
|
|
|
156
239
|
}
|
|
157
240
|
}
|
|
158
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
|
+
|
|
159
363
|
@objc func answerCall(
|
|
160
364
|
_ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
161
365
|
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
@@ -171,6 +375,15 @@ import React
|
|
|
171
375
|
return
|
|
172
376
|
}
|
|
173
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
|
+
|
|
174
387
|
// Mark this call as already answered to prevent duplicate emission
|
|
175
388
|
if var callData = manager.activeCalls[uuid] {
|
|
176
389
|
callData["isAlreadyAnswered"] = true
|
|
@@ -189,6 +402,22 @@ import React
|
|
|
189
402
|
}
|
|
190
403
|
}
|
|
191
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
|
+
|
|
192
421
|
@objc func reportCallConnected(
|
|
193
422
|
_ callUUID: String, resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
194
423
|
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
@@ -207,10 +436,9 @@ import React
|
|
|
207
436
|
provider.reportOutgoingCall(with: uuid, connectedAt: Date())
|
|
208
437
|
|
|
209
438
|
// Fulfill deferred CXAnswerCallAction now that peer connection is ready
|
|
210
|
-
if let pendingAction = manager.
|
|
439
|
+
if let pendingAction = manager.pendingAnswerActions.removeValue(forKey: uuid) {
|
|
211
440
|
NSLog("TelnyxVoice: reportCallConnected - fulfilling deferred CXAnswerCallAction")
|
|
212
441
|
pendingAction.fulfill()
|
|
213
|
-
manager.pendingAnswerAction = nil
|
|
214
442
|
} else {
|
|
215
443
|
// Fallback: ensure audio is enabled for non-push or already-fulfilled cases
|
|
216
444
|
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
@@ -253,6 +481,9 @@ import React
|
|
|
253
481
|
|
|
254
482
|
let endReason = CXCallEndedReason(rawValue: reason.intValue) ?? .remoteEnded
|
|
255
483
|
provider.reportCall(with: uuid, endedAt: Date(), reason: endReason)
|
|
484
|
+
manager.activeCalls.removeValue(forKey: uuid)
|
|
485
|
+
manager.pendingAnswerActions.removeValue(forKey: uuid)?.fail()
|
|
486
|
+
manager.pendingHeldActions.removeValue(forKey: uuid)?.fail()
|
|
256
487
|
resolve(["success": true])
|
|
257
488
|
}
|
|
258
489
|
|
|
@@ -285,12 +516,217 @@ import React
|
|
|
285
516
|
let callUpdate = CXCallUpdate()
|
|
286
517
|
callUpdate.remoteHandle = callHandle
|
|
287
518
|
callUpdate.localizedCallerName = displayName
|
|
519
|
+
callUpdate.supportsHolding = true
|
|
288
520
|
|
|
289
521
|
provider.reportCall(with: uuid, updated: callUpdate)
|
|
290
522
|
resolve(["success": true])
|
|
291
523
|
}
|
|
292
524
|
}
|
|
293
525
|
|
|
526
|
+
// MARK: - Missed Call
|
|
527
|
+
enum TelnyxVoiceUserDefaultsKey {
|
|
528
|
+
static let missedCallNotificationsEnabled = "telnyx_enable_missed_call_notifications"
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private enum TelnyxMissedCallPush {
|
|
532
|
+
static let processedMissedCallIdsKey = "telnyx_processed_missed_call_ids"
|
|
533
|
+
private static let missedCallMarkerKeys: Set<String> = [
|
|
534
|
+
"is_missed_call",
|
|
535
|
+
"message",
|
|
536
|
+
"missed",
|
|
537
|
+
"missed_call",
|
|
538
|
+
"notification_type",
|
|
539
|
+
"push_type",
|
|
540
|
+
"type",
|
|
541
|
+
]
|
|
542
|
+
private static let affirmativeMissedCallValues: Set<String> = [
|
|
543
|
+
"1",
|
|
544
|
+
"missed",
|
|
545
|
+
"missed call",
|
|
546
|
+
"true",
|
|
547
|
+
"yes",
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
static func isMissedCallPayload(_ payload: [AnyHashable: Any]) -> Bool {
|
|
551
|
+
return containsMissedCallMarker(payload)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
static func stableIdentifier(from payload: [AnyHashable: Any]) -> String {
|
|
555
|
+
if let callId = callId(from: payload), !callId.isEmpty {
|
|
556
|
+
return callId
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return "payload-\(hashHex(canonicalString(from: payload)))"
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
static func uuid(from identifier: String) -> UUID {
|
|
563
|
+
if let uuid = UUID(uuidString: identifier) {
|
|
564
|
+
return uuid
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let firstHash = hashHex(identifier)
|
|
568
|
+
let secondHash = hashHex("telnyx-\(identifier)")
|
|
569
|
+
let uuidString =
|
|
570
|
+
"\(firstHash.prefix(8))-\(firstHash.dropFirst(8).prefix(4))-\(firstHash.dropFirst(12).prefix(4))-\(secondHash.prefix(4))-\(secondHash.dropFirst(4).prefix(12))"
|
|
571
|
+
|
|
572
|
+
return UUID(uuidString: uuidString) ?? UUID()
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
static func callId(from payload: [AnyHashable: Any]) -> String? {
|
|
576
|
+
return stringValue(for: ["call_id", "callId"], in: payload)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
static func callerName(from payload: [AnyHashable: Any]) -> String {
|
|
580
|
+
return stringValue(for: ["caller_name", "caller", "from"], in: payload)
|
|
581
|
+
?? callerNumber(from: payload)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
static func callerNumber(from payload: [AnyHashable: Any]) -> String {
|
|
585
|
+
return stringValue(for: ["caller_number", "caller", "from"], in: payload)
|
|
586
|
+
?? "Unknown Caller"
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
static func missedCallNotificationsEnabled() -> Bool {
|
|
590
|
+
// bool(forKey:) returns false for both "unset" and "false"; default to enabled
|
|
591
|
+
// unless the app explicitly stores a preference.
|
|
592
|
+
guard
|
|
593
|
+
UserDefaults.standard.object(
|
|
594
|
+
forKey: TelnyxVoiceUserDefaultsKey.missedCallNotificationsEnabled) != nil
|
|
595
|
+
else {
|
|
596
|
+
return true
|
|
597
|
+
}
|
|
598
|
+
return UserDefaults.standard.bool(
|
|
599
|
+
forKey: TelnyxVoiceUserDefaultsKey.missedCallNotificationsEnabled)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private static func stringValue(for keys: [String], in payload: [AnyHashable: Any]) -> String? {
|
|
603
|
+
if let metadata = payload["metadata"] as? [AnyHashable: Any],
|
|
604
|
+
let value = firstStringValue(for: keys, in: metadata)
|
|
605
|
+
{
|
|
606
|
+
return value
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if let metadata = payload["metadata"] as? [String: Any],
|
|
610
|
+
let value = firstStringValue(for: keys, in: metadata)
|
|
611
|
+
{
|
|
612
|
+
return value
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return firstStringValue(for: keys, in: payload)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private static func firstStringValue(for keys: [String], in dictionary: [AnyHashable: Any])
|
|
619
|
+
-> String?
|
|
620
|
+
{
|
|
621
|
+
for key in keys {
|
|
622
|
+
if let value = dictionary[key] as? String, !value.isEmpty {
|
|
623
|
+
return value
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return nil
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private static func firstStringValue(for keys: [String], in dictionary: [String: Any])
|
|
630
|
+
-> String?
|
|
631
|
+
{
|
|
632
|
+
for key in keys {
|
|
633
|
+
if let value = dictionary[key] as? String, !value.isEmpty {
|
|
634
|
+
return value
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return nil
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private static func containsMissedCallMarker(_ value: Any, key: String? = nil) -> Bool {
|
|
641
|
+
if let key = key, missedCallMarkerKeys.contains(normalizedPayloadKey(key)) {
|
|
642
|
+
if let boolValue = value as? Bool {
|
|
643
|
+
return boolValue
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if let stringValue = value as? String {
|
|
647
|
+
return affirmativeMissedCallValues.contains(normalizedMissedCallValue(stringValue))
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if let stringValue = value as? String {
|
|
652
|
+
let normalized = normalizedMissedCallValue(stringValue)
|
|
653
|
+
if normalized == "missed call" || normalized == "missed" {
|
|
654
|
+
return true
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if let dictionary = value as? [AnyHashable: Any] {
|
|
659
|
+
return dictionary.contains { rawKey, nestedValue in
|
|
660
|
+
containsMissedCallMarker(nestedValue, key: String(describing: rawKey))
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if let dictionary = value as? [String: Any] {
|
|
665
|
+
return dictionary.contains { key, nestedValue in
|
|
666
|
+
containsMissedCallMarker(nestedValue, key: key)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if let array = value as? [Any] {
|
|
671
|
+
return array.contains { containsMissedCallMarker($0) }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return false
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private static func normalizedPayloadKey(_ key: String) -> String {
|
|
678
|
+
return key
|
|
679
|
+
.lowercased()
|
|
680
|
+
.replacingOccurrences(of: "-", with: "_")
|
|
681
|
+
.replacingOccurrences(of: " ", with: "_")
|
|
682
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private static func normalizedMissedCallValue(_ value: String) -> String {
|
|
686
|
+
return value
|
|
687
|
+
.lowercased()
|
|
688
|
+
.replacingOccurrences(of: "_", with: " ")
|
|
689
|
+
.replacingOccurrences(of: "-", with: " ")
|
|
690
|
+
.replacingOccurrences(of: "!", with: "")
|
|
691
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private static func canonicalString(from value: Any) -> String {
|
|
695
|
+
if let dictionary = value as? [AnyHashable: Any] {
|
|
696
|
+
return dictionary
|
|
697
|
+
.map { (String(describing: $0.key), $0.value) }
|
|
698
|
+
.sorted { $0.0 < $1.0 }
|
|
699
|
+
.map { "\($0):\(canonicalString(from: $1))" }
|
|
700
|
+
.joined(separator: "|")
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if let dictionary = value as? [String: Any] {
|
|
704
|
+
return dictionary
|
|
705
|
+
.sorted { $0.key < $1.key }
|
|
706
|
+
.map { "\($0):\(canonicalString(from: $1))" }
|
|
707
|
+
.joined(separator: "|")
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if let array = value as? [Any] {
|
|
711
|
+
return array.map { canonicalString(from: $0) }.joined(separator: ",")
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return String(describing: value)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private static func hashHex(_ value: String) -> String {
|
|
718
|
+
let prime: UInt64 = 1_099_511_628_211
|
|
719
|
+
var hash: UInt64 = 14_695_981_039_346_656_037
|
|
720
|
+
|
|
721
|
+
for byte in value.utf8 {
|
|
722
|
+
hash ^= UInt64(byte)
|
|
723
|
+
hash = hash &* prime
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return String(format: "%016llx", hash)
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
294
730
|
// MARK: - TelnyxCallKitManager
|
|
295
731
|
/// TelnyxCallKitManager - Automatically handles CallKit integration for Telnyx Voice
|
|
296
732
|
/// This class sets up CallKit and VoIP push notifications automatically when the app starts
|
|
@@ -302,8 +738,33 @@ import React
|
|
|
302
738
|
public var voipRegistry: PKPushRegistry?
|
|
303
739
|
public var callKitProvider: CXProvider?
|
|
304
740
|
public var callKitController: CXCallController?
|
|
305
|
-
|
|
306
|
-
public var
|
|
741
|
+
private let stateLock = NSRecursiveLock()
|
|
742
|
+
public lazy var activeCalls = LockedDictionary<UUID, [String: Any]>(lock: stateLock)
|
|
743
|
+
public lazy var pendingAnswerActions = LockedDictionary<UUID, CXAnswerCallAction>(
|
|
744
|
+
lock: stateLock)
|
|
745
|
+
public lazy var pendingHeldActions = LockedDictionary<UUID, CXSetHeldCallAction>(
|
|
746
|
+
lock: stateLock)
|
|
747
|
+
|
|
748
|
+
fileprivate func markCallKitRegistered(_ callUUID: UUID) {
|
|
749
|
+
guard var callData = activeCalls[callUUID] else {
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
callData["isCallKitRegistered"] = true
|
|
754
|
+
activeCalls[callUUID] = callData
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
fileprivate func isCallKitRegistered(_ callUUID: UUID) -> Bool {
|
|
758
|
+
guard let callData = activeCalls[callUUID] else {
|
|
759
|
+
return false
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if callData["direction"] as? String == "incoming" {
|
|
763
|
+
return callData["isCallKitRegistered"] as? Bool == true
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return true
|
|
767
|
+
}
|
|
307
768
|
|
|
308
769
|
private override init() {
|
|
309
770
|
super.init()
|
|
@@ -324,18 +785,27 @@ import React
|
|
|
324
785
|
}
|
|
325
786
|
}
|
|
326
787
|
|
|
327
|
-
|
|
788
|
+
@discardableResult
|
|
789
|
+
public func setupSynchronously() -> CXProvider {
|
|
328
790
|
// Only initialize once
|
|
329
|
-
|
|
791
|
+
if let callKitProvider = callKitProvider {
|
|
330
792
|
NSLog("TelnyxVoice: CallKit already setup, skipping")
|
|
331
|
-
return
|
|
793
|
+
return callKitProvider
|
|
332
794
|
}
|
|
333
795
|
|
|
334
796
|
// CRITICAL: Setup CallKit synchronously for terminated app VoIP push handling
|
|
335
797
|
// This MUST happen in the same run loop as the VoIP push
|
|
336
798
|
NSLog("TelnyxVoice: Synchronous CallKit setup for terminated app scenario...")
|
|
337
799
|
setupCallKit()
|
|
800
|
+
if let callKitProvider = callKitProvider {
|
|
801
|
+
NSLog("TelnyxVoice: ✅ CallKit provider ready for terminated app handling")
|
|
802
|
+
return callKitProvider
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
NSLog("TelnyxVoice: CallKit setup did not create a provider; creating fallback provider")
|
|
806
|
+
let callKitProvider = installCallKitProvider()
|
|
338
807
|
NSLog("TelnyxVoice: ✅ CallKit provider ready for terminated app handling")
|
|
808
|
+
return callKitProvider
|
|
339
809
|
}
|
|
340
810
|
|
|
341
811
|
private func setupAutomatically() {
|
|
@@ -366,6 +836,10 @@ import React
|
|
|
366
836
|
return
|
|
367
837
|
}
|
|
368
838
|
|
|
839
|
+
_ = installCallKitProvider()
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private func installCallKitProvider() -> CXProvider {
|
|
369
843
|
// Use the localizedName from the app's bundle display name or fallback
|
|
370
844
|
let appName =
|
|
371
845
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
|
@@ -374,19 +848,66 @@ import React
|
|
|
374
848
|
|
|
375
849
|
let configuration = CXProviderConfiguration(localizedName: appName)
|
|
376
850
|
configuration.supportsVideo = false
|
|
377
|
-
configuration.maximumCallGroups =
|
|
851
|
+
configuration.maximumCallGroups = 2
|
|
378
852
|
configuration.maximumCallsPerCallGroup = 1
|
|
379
853
|
configuration.supportedHandleTypes = [.phoneNumber, .generic]
|
|
380
854
|
configuration.includesCallsInRecents = true
|
|
381
855
|
|
|
382
|
-
|
|
383
|
-
|
|
856
|
+
let provider = CXProvider(configuration: configuration)
|
|
857
|
+
provider.setDelegate(self, queue: nil)
|
|
858
|
+
callKitProvider = provider
|
|
384
859
|
callKitController = CXCallController()
|
|
385
860
|
|
|
386
861
|
NSLog("TelnyxVoice: CallKit provider and controller created")
|
|
862
|
+
return provider
|
|
387
863
|
}
|
|
388
864
|
|
|
389
|
-
|
|
865
|
+
fileprivate func reportAndEndWatchdogCall(
|
|
866
|
+
callUUID: UUID,
|
|
867
|
+
callerName: String,
|
|
868
|
+
callerNumber: String,
|
|
869
|
+
payload: [AnyHashable: Any],
|
|
870
|
+
source: String,
|
|
871
|
+
endedReason: CXCallEndedReason,
|
|
872
|
+
completion: (() -> Void)? = nil
|
|
873
|
+
) {
|
|
874
|
+
let callKitProvider = setupSynchronously()
|
|
875
|
+
|
|
876
|
+
activeCalls[callUUID] = [
|
|
877
|
+
"caller": callerName,
|
|
878
|
+
"handle": callerNumber,
|
|
879
|
+
"payload": payload,
|
|
880
|
+
"uuid": callUUID.uuidString,
|
|
881
|
+
"direction": "incoming",
|
|
882
|
+
"source": source,
|
|
883
|
+
"watchdogPlaceholder": true,
|
|
884
|
+
]
|
|
885
|
+
|
|
886
|
+
let handle = CXHandle(type: .phoneNumber, value: callerNumber)
|
|
887
|
+
let callUpdate = CXCallUpdate()
|
|
888
|
+
callUpdate.remoteHandle = handle
|
|
889
|
+
callUpdate.hasVideo = false
|
|
890
|
+
callUpdate.localizedCallerName = callerName
|
|
891
|
+
callUpdate.supportsHolding = true
|
|
892
|
+
|
|
893
|
+
callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
|
|
894
|
+
if let error = error {
|
|
895
|
+
NSLog(
|
|
896
|
+
"TelnyxVoice: Watchdog CallKit report failed for \(source): \(error.localizedDescription)"
|
|
897
|
+
)
|
|
898
|
+
} else {
|
|
899
|
+
NSLog("TelnyxVoice: Watchdog CallKit call reported for \(source)")
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: endedReason)
|
|
903
|
+
self.activeCalls.removeValue(forKey: callUUID)
|
|
904
|
+
|
|
905
|
+
self.pendingAnswerActions.removeValue(forKey: callUUID)?.fail()
|
|
906
|
+
self.pendingHeldActions.removeValue(forKey: callUUID)?.fail()
|
|
907
|
+
|
|
908
|
+
completion?()
|
|
909
|
+
}
|
|
910
|
+
}
|
|
390
911
|
|
|
391
912
|
private func observeAppDelegate() {
|
|
392
913
|
// Automatically hook into app lifecycle if needed
|
|
@@ -404,6 +925,243 @@ import React
|
|
|
404
925
|
setupCallKit()
|
|
405
926
|
}
|
|
406
927
|
}
|
|
928
|
+
|
|
929
|
+
@discardableResult
|
|
930
|
+
public func handleMissedCallPushIfNeeded(
|
|
931
|
+
_ payload: [AnyHashable: Any],
|
|
932
|
+
completion: (() -> Void)? = nil
|
|
933
|
+
) -> Bool {
|
|
934
|
+
guard TelnyxMissedCallPush.isMissedCallPayload(payload) else {
|
|
935
|
+
return false
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
NSLog("TelnyxVoice: Missed call VoIP push received: \(payload)")
|
|
939
|
+
let callKitProvider = setupSynchronously()
|
|
940
|
+
|
|
941
|
+
let callId = TelnyxMissedCallPush.callId(from: payload)
|
|
942
|
+
let missedCallId = TelnyxMissedCallPush.stableIdentifier(from: payload)
|
|
943
|
+
let callUUID = TelnyxMissedCallPush.uuid(from: missedCallId)
|
|
944
|
+
let callerName = TelnyxMissedCallPush.callerName(from: payload)
|
|
945
|
+
let callerNumber = TelnyxMissedCallPush.callerNumber(from: payload)
|
|
946
|
+
|
|
947
|
+
if hasProcessedMissedCall(id: missedCallId) {
|
|
948
|
+
NSLog("TelnyxVoice: Suppressing duplicate missed call notification: \(missedCallId)")
|
|
949
|
+
reportAndEndWatchdogCall(
|
|
950
|
+
callUUID: UUID(),
|
|
951
|
+
callerName: callerName,
|
|
952
|
+
callerNumber: callerNumber,
|
|
953
|
+
payload: payload,
|
|
954
|
+
source: "missed_call_duplicate_watchdog",
|
|
955
|
+
endedReason: .remoteEnded,
|
|
956
|
+
completion: completion
|
|
957
|
+
)
|
|
958
|
+
return true
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
let finishMissedCall = {
|
|
962
|
+
callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
|
|
963
|
+
self.activeCalls.removeValue(forKey: callUUID)
|
|
964
|
+
|
|
965
|
+
self.pendingAnswerActions.removeValue(forKey: callUUID)?.fail()
|
|
966
|
+
self.pendingHeldActions.removeValue(forKey: callUUID)?.fail()
|
|
967
|
+
|
|
968
|
+
NSLog("TelnyxVoice: Ended stale CallKit call for missed call UUID: \(callUUID)")
|
|
969
|
+
self.clearPendingPushData(for: callId)
|
|
970
|
+
self.showMissedCallNotification(
|
|
971
|
+
callerName: callerName,
|
|
972
|
+
callerNumber: callerNumber,
|
|
973
|
+
missedCallId: missedCallId
|
|
974
|
+
)
|
|
975
|
+
self.markMissedCallProcessed(id: missedCallId)
|
|
976
|
+
completion?()
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if let activeCall = activeCalls[callUUID] {
|
|
980
|
+
guard activeCall["source"] as? String == "missed_call_push" else {
|
|
981
|
+
NSLog("TelnyxVoice: Suppressing missed call notification because UUID belongs to an active call: \(callUUID)")
|
|
982
|
+
markMissedCallProcessed(id: missedCallId)
|
|
983
|
+
reportAndEndWatchdogCall(
|
|
984
|
+
callUUID: UUID(),
|
|
985
|
+
callerName: callerName,
|
|
986
|
+
callerNumber: callerNumber,
|
|
987
|
+
payload: payload,
|
|
988
|
+
source: "missed_call_collision_watchdog",
|
|
989
|
+
endedReason: .remoteEnded,
|
|
990
|
+
completion: completion
|
|
991
|
+
)
|
|
992
|
+
return true
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
reportAndEndWatchdogCall(
|
|
996
|
+
callUUID: UUID(),
|
|
997
|
+
callerName: callerName,
|
|
998
|
+
callerNumber: callerNumber,
|
|
999
|
+
payload: payload,
|
|
1000
|
+
source: "missed_call_active_watchdog",
|
|
1001
|
+
endedReason: .remoteEnded
|
|
1002
|
+
) {
|
|
1003
|
+
finishMissedCall()
|
|
1004
|
+
}
|
|
1005
|
+
return true
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
activeCalls[callUUID] = [
|
|
1009
|
+
"caller": callerName,
|
|
1010
|
+
"handle": callerNumber,
|
|
1011
|
+
"payload": payload,
|
|
1012
|
+
"uuid": callUUID.uuidString,
|
|
1013
|
+
"direction": "incoming",
|
|
1014
|
+
"source": "missed_call_push",
|
|
1015
|
+
]
|
|
1016
|
+
|
|
1017
|
+
let handle = CXHandle(type: .phoneNumber, value: callerNumber)
|
|
1018
|
+
let callUpdate = CXCallUpdate()
|
|
1019
|
+
callUpdate.remoteHandle = handle
|
|
1020
|
+
callUpdate.hasVideo = false
|
|
1021
|
+
callUpdate.localizedCallerName = callerName
|
|
1022
|
+
callUpdate.supportsHolding = true
|
|
1023
|
+
|
|
1024
|
+
callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
|
|
1025
|
+
if let error = error {
|
|
1026
|
+
NSLog("TelnyxVoice: Missed call CallKit report failed: \(error.localizedDescription)")
|
|
1027
|
+
callKitProvider.reportCall(with: callUUID, endedAt: Date(), reason: .failed)
|
|
1028
|
+
self.activeCalls.removeValue(forKey: callUUID)
|
|
1029
|
+
self.clearPendingPushData(for: callId)
|
|
1030
|
+
completion?()
|
|
1031
|
+
return
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
finishMissedCall()
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
return true
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
fileprivate func clearPendingPushData(for callId: String?) {
|
|
1041
|
+
if hasPendingPushData(forDifferentCallThan: callId) {
|
|
1042
|
+
NSLog("TelnyxVoice: Skipping pending push cleanup for missed call; pending data belongs to a different call")
|
|
1043
|
+
return
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
UserDefaults.standard.removeObject(forKey: "pending_push_action")
|
|
1047
|
+
UserDefaults.standard.removeObject(forKey: "pending_push_metadata")
|
|
1048
|
+
UserDefaults.standard.removeObject(forKey: "pending_voip_push")
|
|
1049
|
+
UserDefaults.standard.removeObject(forKey: "pending_voip_action")
|
|
1050
|
+
UserDefaults.standard.removeObject(forKey: "@pending_callkit_uuid")
|
|
1051
|
+
UserDefaults.standard.synchronize()
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
private func hasPendingPushData(forDifferentCallThan missedCallId: String?) -> Bool {
|
|
1055
|
+
guard let missedCallId = missedCallId, !missedCallId.isEmpty else {
|
|
1056
|
+
return false
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
let pendingIds = [
|
|
1060
|
+
pendingCallId(fromJsonForKey: "pending_push_metadata"),
|
|
1061
|
+
pendingCallId(fromJsonForKey: "pending_voip_push"),
|
|
1062
|
+
UserDefaults.standard.string(forKey: "@pending_callkit_uuid"),
|
|
1063
|
+
].compactMap { $0 }
|
|
1064
|
+
|
|
1065
|
+
return pendingIds.contains {
|
|
1066
|
+
!$0.isEmpty && $0.caseInsensitiveCompare(missedCallId) != .orderedSame
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private func pendingCallId(fromJsonForKey key: String) -> String? {
|
|
1071
|
+
guard let jsonString = UserDefaults.standard.string(forKey: key),
|
|
1072
|
+
let data = jsonString.data(using: .utf8),
|
|
1073
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
1074
|
+
else {
|
|
1075
|
+
return nil
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if let directCallId = callId(from: json) {
|
|
1079
|
+
return directCallId
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if let payload = json["payload"] as? [String: Any] {
|
|
1083
|
+
return callId(from: payload)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return nil
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
private func callId(from dictionary: [String: Any]) -> String? {
|
|
1090
|
+
for key in ["call_id", "callId", "telnyx_call_id"] {
|
|
1091
|
+
if let value = dictionary[key] as? String, !value.isEmpty {
|
|
1092
|
+
return value
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if let metadata = dictionary["metadata"] as? [String: Any] {
|
|
1097
|
+
return callId(from: metadata)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
return nil
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
private func hasProcessedMissedCall(id: String) -> Bool {
|
|
1104
|
+
return processedMissedCallIds().contains(id)
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
private func markMissedCallProcessed(id: String) {
|
|
1108
|
+
var ids = processedMissedCallIds().filter { $0 != id }
|
|
1109
|
+
ids.append(id)
|
|
1110
|
+
UserDefaults.standard.set(Array(ids.suffix(50)), forKey: TelnyxMissedCallPush.processedMissedCallIdsKey)
|
|
1111
|
+
UserDefaults.standard.synchronize()
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private func processedMissedCallIds() -> [String] {
|
|
1115
|
+
return UserDefaults.standard.stringArray(
|
|
1116
|
+
forKey: TelnyxMissedCallPush.processedMissedCallIdsKey) ?? []
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
private func showMissedCallNotification(
|
|
1120
|
+
callerName: String, callerNumber: String, missedCallId: String
|
|
1121
|
+
) {
|
|
1122
|
+
guard TelnyxMissedCallPush.missedCallNotificationsEnabled() else {
|
|
1123
|
+
NSLog("TelnyxVoice: Missed call local notification disabled")
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
let content = UNMutableNotificationContent()
|
|
1128
|
+
content.title = "Missed Call"
|
|
1129
|
+
content.body = "You missed a call from \(callerName)"
|
|
1130
|
+
content.sound = .default
|
|
1131
|
+
content.userInfo = [
|
|
1132
|
+
"call_id": missedCallId,
|
|
1133
|
+
"caller_name": callerName,
|
|
1134
|
+
"caller_number": callerNumber,
|
|
1135
|
+
]
|
|
1136
|
+
|
|
1137
|
+
let identifier = "telnyx.missed-call.\(missedCallId)"
|
|
1138
|
+
let request = UNNotificationRequest(
|
|
1139
|
+
identifier: identifier,
|
|
1140
|
+
content: content,
|
|
1141
|
+
trigger: nil
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
let notificationCenter = UNUserNotificationCenter.current()
|
|
1145
|
+
notificationCenter.getNotificationSettings { settings in
|
|
1146
|
+
guard settings.authorizationStatus == .authorized
|
|
1147
|
+
|| settings.authorizationStatus == .provisional
|
|
1148
|
+
|| settings.authorizationStatus == .ephemeral
|
|
1149
|
+
else {
|
|
1150
|
+
NSLog(
|
|
1151
|
+
"TelnyxVoice: Missed call notification not shown; notification authorization status: \(settings.authorizationStatus.rawValue)"
|
|
1152
|
+
)
|
|
1153
|
+
return
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
notificationCenter.add(request) { error in
|
|
1157
|
+
if let error = error {
|
|
1158
|
+
NSLog("TelnyxVoice: Failed to show missed call notification: \(error)")
|
|
1159
|
+
} else {
|
|
1160
|
+
NSLog("TelnyxVoice: Missed call notification scheduled")
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
407
1165
|
}
|
|
408
1166
|
|
|
409
1167
|
// MARK: - PKPushRegistryDelegate
|
|
@@ -458,6 +1216,10 @@ import React
|
|
|
458
1216
|
) {
|
|
459
1217
|
NSLog("TelnyxVoice: Received VoIP push notification: \(payload.dictionaryPayload)")
|
|
460
1218
|
|
|
1219
|
+
if handleMissedCallPushIfNeeded(payload.dictionaryPayload, completion: completion) {
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
1222
|
+
|
|
461
1223
|
// Store the VoIP push data for VoicePnBridge (same format as AppDelegate was using)
|
|
462
1224
|
do {
|
|
463
1225
|
let jsonData = try JSONSerialization.data(withJSONObject: payload.dictionaryPayload)
|
|
@@ -499,6 +1261,7 @@ import React
|
|
|
499
1261
|
"uuid": callUUID.uuidString,
|
|
500
1262
|
"direction": "incoming",
|
|
501
1263
|
"source": "push",
|
|
1264
|
+
"isCallKitRegistered": false,
|
|
502
1265
|
]
|
|
503
1266
|
|
|
504
1267
|
let handle = CXHandle(type: .phoneNumber, value: caller)
|
|
@@ -506,6 +1269,7 @@ import React
|
|
|
506
1269
|
callUpdate.remoteHandle = handle
|
|
507
1270
|
callUpdate.hasVideo = false
|
|
508
1271
|
callUpdate.localizedCallerName = caller
|
|
1272
|
+
callUpdate.supportsHolding = true
|
|
509
1273
|
|
|
510
1274
|
callKitProvider?.reportNewIncomingCall(with: callUUID, update: callUpdate) {
|
|
511
1275
|
[weak self] error in
|
|
@@ -513,6 +1277,7 @@ import React
|
|
|
513
1277
|
NSLog("TelnyxVoice: CallKit error: \(error.localizedDescription)")
|
|
514
1278
|
self?.activeCalls.removeValue(forKey: callUUID)
|
|
515
1279
|
} else {
|
|
1280
|
+
self?.markCallKitRegistered(callUUID)
|
|
516
1281
|
NSLog("TelnyxVoice: CallKit incoming call reported successfully")
|
|
517
1282
|
}
|
|
518
1283
|
completion()
|
|
@@ -527,12 +1292,35 @@ import React
|
|
|
527
1292
|
NSLog("📞 TelnyxVoice: CALLKIT PROVIDER RESET - Provider: \(provider)")
|
|
528
1293
|
NSLog("TelnyxVoice: CallKit provider reset - ending all active calls")
|
|
529
1294
|
activeCalls.removeAll()
|
|
1295
|
+
pendingAnswerActions.values.forEach { $0.fail() }
|
|
1296
|
+
pendingAnswerActions.removeAll()
|
|
1297
|
+
pendingHeldActions.values.forEach { $0.fail() }
|
|
1298
|
+
pendingHeldActions.removeAll()
|
|
530
1299
|
}
|
|
531
1300
|
|
|
532
1301
|
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
|
533
1302
|
NSLog("📞 TelnyxVoice: CALLKIT ANSWER ACTION - Provider: \(provider), Action: \(action)")
|
|
534
1303
|
NSLog("TelnyxVoice: User answered call with UUID: \(action.callUUID)")
|
|
535
1304
|
|
|
1305
|
+
if activeCalls[action.callUUID]?["watchdogPlaceholder"] as? Bool == true {
|
|
1306
|
+
NSLog("TelnyxVoice: Failing answer for watchdog placeholder call: \(action.callUUID)")
|
|
1307
|
+
activeCalls.removeValue(forKey: action.callUUID)
|
|
1308
|
+
action.fail()
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
guard isCallKitRegistered(action.callUUID) else {
|
|
1313
|
+
NSLog("TelnyxVoice: Failing answer for unknown UUID: \(action.callUUID)")
|
|
1314
|
+
action.fail()
|
|
1315
|
+
return
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
guard pendingAnswerActions[action.callUUID] == nil else {
|
|
1319
|
+
NSLog("TelnyxVoice: Failing duplicate answer for UUID: \(action.callUUID)")
|
|
1320
|
+
action.fail()
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
|
|
536
1324
|
// Check if this is a programmatic answer (call already answered in WebRTC)
|
|
537
1325
|
// vs a user answer from CallKit UI
|
|
538
1326
|
if let callData = activeCalls[action.callUUID],
|
|
@@ -542,25 +1330,83 @@ import React
|
|
|
542
1330
|
NSLog("TelnyxVoice: Call already answered in WebRTC, skipping emission")
|
|
543
1331
|
} else {
|
|
544
1332
|
// Notify React Native via CallKit bridge (only for user-initiated answers)
|
|
545
|
-
CallKitBridge.shared?.emitCallEvent(
|
|
1333
|
+
let eventDelivered = CallKitBridge.shared?.emitCallEvent(
|
|
546
1334
|
"CallKitDidPerformAnswerCallAction", callUUID: action.callUUID,
|
|
547
|
-
callData: activeCalls[action.callUUID])
|
|
1335
|
+
callData: activeCalls[action.callUUID]) ?? false
|
|
1336
|
+
if !eventDelivered {
|
|
1337
|
+
UserDefaults.standard.set(action.callUUID.uuidString, forKey: "pending_callkit_answer")
|
|
1338
|
+
UserDefaults.standard.synchronize()
|
|
1339
|
+
NSLog("TelnyxVoice: Stored pending CallKit answer for UUID: \(action.callUUID)")
|
|
1340
|
+
}
|
|
548
1341
|
}
|
|
549
1342
|
|
|
550
1343
|
// Defer action.fulfill() until reportCallConnected when peer connection is ready
|
|
551
1344
|
NSLog("TelnyxVoice: Deferring CXAnswerCallAction.fulfill() until peer connection is ready")
|
|
552
|
-
self.
|
|
1345
|
+
self.pendingAnswerActions[action.callUUID] = action
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
|
|
1349
|
+
NSLog(
|
|
1350
|
+
"TelnyxVoice: User requested held=\(action.isOnHold) for UUID: \(action.callUUID)"
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
guard activeCalls[action.callUUID] != nil else {
|
|
1354
|
+
NSLog("TelnyxVoice: Failing held action for unknown UUID: \(action.callUUID)")
|
|
1355
|
+
action.fail()
|
|
1356
|
+
return
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
guard pendingHeldActions[action.callUUID] == nil else {
|
|
1360
|
+
NSLog("TelnyxVoice: Failing duplicate held action for UUID: \(action.callUUID)")
|
|
1361
|
+
action.fail()
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
pendingHeldActions[action.callUUID] = action
|
|
1366
|
+
let emitted = CallKitBridge.shared?.emitHeldCallEvent(
|
|
1367
|
+
callUUID: action.callUUID,
|
|
1368
|
+
isOnHold: action.isOnHold,
|
|
1369
|
+
callData: activeCalls[action.callUUID]
|
|
1370
|
+
) ?? false
|
|
1371
|
+
|
|
1372
|
+
guard emitted else {
|
|
1373
|
+
pendingHeldActions.removeValue(forKey: action.callUUID)
|
|
1374
|
+
action.fail()
|
|
1375
|
+
return
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak action] in
|
|
1379
|
+
guard let self = self, let action = action,
|
|
1380
|
+
self.pendingHeldActions[action.callUUID] === action
|
|
1381
|
+
else { return }
|
|
1382
|
+
|
|
1383
|
+
NSLog("TelnyxVoice: Held action timed out for UUID: \(action.callUUID)")
|
|
1384
|
+
self.pendingHeldActions.removeValue(forKey: action.callUUID)
|
|
1385
|
+
action.fail()
|
|
1386
|
+
}
|
|
553
1387
|
}
|
|
554
1388
|
|
|
555
1389
|
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
|
556
1390
|
NSLog("TelnyxVoice: User ended call with UUID: \(action.callUUID)")
|
|
557
1391
|
|
|
1392
|
+
guard let callData = activeCalls[action.callUUID] else {
|
|
1393
|
+
NSLog("TelnyxVoice: Failing end action for unknown UUID: \(action.callUUID)")
|
|
1394
|
+
action.fail()
|
|
1395
|
+
return
|
|
1396
|
+
}
|
|
1397
|
+
if UserDefaults.standard.string(forKey: "pending_callkit_answer") == action.callUUID.uuidString {
|
|
1398
|
+
UserDefaults.standard.removeObject(forKey: "pending_callkit_answer")
|
|
1399
|
+
UserDefaults.standard.synchronize()
|
|
1400
|
+
}
|
|
1401
|
+
|
|
558
1402
|
// Notify React Native via CallKit bridge
|
|
559
1403
|
CallKitBridge.shared?.emitCallEvent(
|
|
560
1404
|
"CallKitDidPerformEndCallAction", callUUID: action.callUUID,
|
|
561
|
-
callData:
|
|
1405
|
+
callData: callData)
|
|
562
1406
|
|
|
563
1407
|
activeCalls.removeValue(forKey: action.callUUID)
|
|
1408
|
+
pendingAnswerActions.removeValue(forKey: action.callUUID)?.fail()
|
|
1409
|
+
pendingHeldActions.removeValue(forKey: action.callUUID)?.fail()
|
|
564
1410
|
NSLog("📞 TelnyxVoice: Fulfilling CXEndCallAction for call UUID: \(action.callUUID)")
|
|
565
1411
|
action.fulfill()
|
|
566
1412
|
NSLog("📞 TelnyxVoice: ✅ CXEndCallAction fulfilled successfully")
|
|
@@ -574,11 +1420,26 @@ import React
|
|
|
574
1420
|
"CallKitDidReceiveStartCallAction", callUUID: action.callUUID,
|
|
575
1421
|
callData: activeCalls[action.callUUID])
|
|
576
1422
|
|
|
1423
|
+
let callUpdate = CXCallUpdate()
|
|
1424
|
+
callUpdate.remoteHandle = action.handle
|
|
1425
|
+
callUpdate.hasVideo = false
|
|
1426
|
+
callUpdate.supportsHolding = true
|
|
1427
|
+
provider.reportCall(with: action.callUUID, updated: callUpdate)
|
|
1428
|
+
|
|
577
1429
|
NSLog("📞 TelnyxVoice: Fulfilling CXStartCallAction for call UUID: \(action.callUUID)")
|
|
578
1430
|
action.fulfill()
|
|
579
1431
|
NSLog("📞 TelnyxVoice: ✅ CXStartCallAction fulfilled successfully")
|
|
580
1432
|
}
|
|
581
1433
|
|
|
1434
|
+
public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
|
|
1435
|
+
if let answerAction = action as? CXAnswerCallAction {
|
|
1436
|
+
pendingAnswerActions.removeValue(forKey: answerAction.callUUID)
|
|
1437
|
+
} else if let heldAction = action as? CXSetHeldCallAction {
|
|
1438
|
+
pendingHeldActions.removeValue(forKey: heldAction.callUUID)
|
|
1439
|
+
}
|
|
1440
|
+
action.fail()
|
|
1441
|
+
}
|
|
1442
|
+
|
|
582
1443
|
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
|
583
1444
|
NSLog("TelnyxVoice: Audio session activated by CallKit")
|
|
584
1445
|
|
|
@@ -665,6 +1526,33 @@ import React
|
|
|
665
1526
|
super.init()
|
|
666
1527
|
}
|
|
667
1528
|
|
|
1529
|
+
private func storePendingVoipPush(_ payload: [AnyHashable: Any]) {
|
|
1530
|
+
do {
|
|
1531
|
+
let jsonData = try JSONSerialization.data(withJSONObject: payload)
|
|
1532
|
+
let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
|
|
1533
|
+
|
|
1534
|
+
UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
|
|
1535
|
+
UserDefaults.standard.set(jsonString, forKey: "pending_push_metadata")
|
|
1536
|
+
|
|
1537
|
+
let voipPushData = [
|
|
1538
|
+
"payload": payload
|
|
1539
|
+
]
|
|
1540
|
+
let voipJsonData = try JSONSerialization.data(withJSONObject: voipPushData)
|
|
1541
|
+
let voipJsonString = String(data: voipJsonData, encoding: .utf8) ?? ""
|
|
1542
|
+
UserDefaults.standard.set(voipJsonString, forKey: "pending_voip_push")
|
|
1543
|
+
UserDefaults.standard.synchronize()
|
|
1544
|
+
NSLog(
|
|
1545
|
+
"[TelnyxVoipPushHandler] Stored VoIP push data after CallKit registration succeeded"
|
|
1546
|
+
)
|
|
1547
|
+
} catch {
|
|
1548
|
+
NSLog("[TelnyxVoipPushHandler] Error converting VoIP payload to JSON: \(error)")
|
|
1549
|
+
UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
|
|
1550
|
+
UserDefaults.standard.set("{}", forKey: "pending_push_metadata")
|
|
1551
|
+
UserDefaults.standard.set("{\"payload\":{}}", forKey: "pending_voip_push")
|
|
1552
|
+
UserDefaults.standard.synchronize()
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
668
1556
|
/**
|
|
669
1557
|
* Initialize VoIP push registration
|
|
670
1558
|
* Call this from the main app's AppDelegate didFinishLaunchingWithOptions
|
|
@@ -698,6 +1586,13 @@ import React
|
|
|
698
1586
|
NSLog("[TelnyxVoipPushHandler] VoIP push received for type: \(type.rawValue)")
|
|
699
1587
|
NSLog("[TelnyxVoipPushHandler] VoIP payload: \(payload.dictionaryPayload)")
|
|
700
1588
|
|
|
1589
|
+
if TelnyxCallKitManager.shared.handleMissedCallPushIfNeeded(
|
|
1590
|
+
payload.dictionaryPayload,
|
|
1591
|
+
completion: completion
|
|
1592
|
+
) {
|
|
1593
|
+
return
|
|
1594
|
+
}
|
|
1595
|
+
|
|
701
1596
|
// Configure audio session early for VoIP call
|
|
702
1597
|
let audioSession = AVAudioSession.sharedInstance()
|
|
703
1598
|
do {
|
|
@@ -706,35 +1601,6 @@ import React
|
|
|
706
1601
|
} catch {
|
|
707
1602
|
NSLog("Failed to activate audio session: \(error)")
|
|
708
1603
|
}
|
|
709
|
-
// Store the VoIP push data for VoicePnBridge
|
|
710
|
-
do {
|
|
711
|
-
let jsonData = try JSONSerialization.data(withJSONObject: payload.dictionaryPayload)
|
|
712
|
-
let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
|
|
713
|
-
|
|
714
|
-
// Store for TelnyxVoiceApp (push action flow)
|
|
715
|
-
UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
|
|
716
|
-
UserDefaults.standard.set(jsonString, forKey: "pending_push_metadata")
|
|
717
|
-
|
|
718
|
-
// ALSO store for VoicePnBridge.getPendingVoipPush() (CallKit coordinator flow)
|
|
719
|
-
// This creates a structured object with payload property
|
|
720
|
-
let voipPushData = [
|
|
721
|
-
"payload": payload.dictionaryPayload
|
|
722
|
-
]
|
|
723
|
-
let voipJsonData = try JSONSerialization.data(withJSONObject: voipPushData)
|
|
724
|
-
let voipJsonString = String(data: voipJsonData, encoding: .utf8) ?? ""
|
|
725
|
-
UserDefaults.standard.set(voipJsonString, forKey: "pending_voip_push")
|
|
726
|
-
|
|
727
|
-
UserDefaults.standard.synchronize()
|
|
728
|
-
NSLog(
|
|
729
|
-
"[TelnyxVoipPushHandler] Stored VoIP push data for both TelnyxVoiceApp and VoicePnBridge"
|
|
730
|
-
)
|
|
731
|
-
} catch {
|
|
732
|
-
NSLog("[TelnyxVoipPushHandler] Error converting VoIP payload to JSON: \(error)")
|
|
733
|
-
UserDefaults.standard.set("incoming_call", forKey: "pending_push_action")
|
|
734
|
-
UserDefaults.standard.set("{}", forKey: "pending_push_metadata")
|
|
735
|
-
UserDefaults.standard.set("{\"payload\":{}}", forKey: "pending_voip_push")
|
|
736
|
-
UserDefaults.standard.synchronize()
|
|
737
|
-
}
|
|
738
1604
|
|
|
739
1605
|
// Extract caller information and call_id from the payload
|
|
740
1606
|
var callerName = "Unknown Caller"
|
|
@@ -755,12 +1621,20 @@ import React
|
|
|
755
1621
|
callId = payload.dictionaryPayload["call_id"] as? String
|
|
756
1622
|
}
|
|
757
1623
|
|
|
758
|
-
// Use call_id as CallKit UUID to ensure matching with WebRTC
|
|
1624
|
+
// Use call_id as CallKit UUID to ensure matching with WebRTC.
|
|
759
1625
|
guard let callIdString = callId, let callUUID = UUID(uuidString: callIdString) else {
|
|
760
1626
|
NSLog(
|
|
761
|
-
"[TelnyxVoipPushHandler]
|
|
1627
|
+
"[TelnyxVoipPushHandler] ⚠️ Invalid or missing call_id in payload (\(callId ?? "nil")); reporting failed watchdog placeholder"
|
|
1628
|
+
)
|
|
1629
|
+
TelnyxCallKitManager.shared.reportAndEndWatchdogCall(
|
|
1630
|
+
callUUID: UUID(),
|
|
1631
|
+
callerName: callerName,
|
|
1632
|
+
callerNumber: callerNumber,
|
|
1633
|
+
payload: payload.dictionaryPayload,
|
|
1634
|
+
source: "malformed_push_watchdog",
|
|
1635
|
+
endedReason: .failed,
|
|
1636
|
+
completion: completion
|
|
762
1637
|
)
|
|
763
|
-
completion()
|
|
764
1638
|
return
|
|
765
1639
|
}
|
|
766
1640
|
|
|
@@ -776,16 +1650,7 @@ import React
|
|
|
776
1650
|
|
|
777
1651
|
// CRITICAL: Setup CallKit SYNCHRONOUSLY - no async dispatch allowed
|
|
778
1652
|
// This must happen in the same run loop as the VoIP push
|
|
779
|
-
callKitManager.setupSynchronously()
|
|
780
|
-
|
|
781
|
-
// Ensure we have a valid CallKit provider after setup
|
|
782
|
-
guard let callKitProvider = callKitManager.callKitProvider else {
|
|
783
|
-
NSLog(
|
|
784
|
-
"[TelnyxVoipPushHandler] ❌ FATAL: CallKit provider not available after synchronous setup!"
|
|
785
|
-
)
|
|
786
|
-
completion()
|
|
787
|
-
return
|
|
788
|
-
}
|
|
1653
|
+
let callKitProvider = callKitManager.setupSynchronously()
|
|
789
1654
|
|
|
790
1655
|
NSLog("[TelnyxVoipPushHandler] ✅ CallKit provider ready, reporting incoming call")
|
|
791
1656
|
|
|
@@ -799,6 +1664,7 @@ import React
|
|
|
799
1664
|
"uuid": callUUID.uuidString,
|
|
800
1665
|
"direction": "incoming",
|
|
801
1666
|
"source": isAppRunning ? "push_notification" : "terminated_app_push",
|
|
1667
|
+
"isCallKitRegistered": false,
|
|
802
1668
|
]
|
|
803
1669
|
|
|
804
1670
|
// Report to CallKit immediately
|
|
@@ -807,18 +1673,27 @@ import React
|
|
|
807
1673
|
callUpdate.remoteHandle = handle
|
|
808
1674
|
callUpdate.hasVideo = false
|
|
809
1675
|
callUpdate.localizedCallerName = callerName
|
|
1676
|
+
callUpdate.supportsHolding = true
|
|
810
1677
|
|
|
811
1678
|
callKitProvider.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
|
|
812
1679
|
if let error = error {
|
|
1680
|
+
let nsError = error as NSError
|
|
1681
|
+
let reason =
|
|
1682
|
+
nsError.domain == CXErrorDomainIncomingCall && nsError.code == 3
|
|
1683
|
+
? "filteredByDoNotDisturb" : "registrationRejected"
|
|
813
1684
|
NSLog(
|
|
814
|
-
"[TelnyxVoipPushHandler]
|
|
1685
|
+
"[TelnyxVoipPushHandler] CallKit incoming registration rejected: domain=\(nsError.domain), code=\(nsError.code), reason=\(reason)"
|
|
815
1686
|
)
|
|
816
1687
|
callKitManager.activeCalls.removeValue(forKey: callUUID)
|
|
1688
|
+
callKitManager.clearPendingPushData(for: callUUID.uuidString)
|
|
817
1689
|
} else {
|
|
818
1690
|
NSLog(
|
|
819
1691
|
"[TelnyxVoipPushHandler] ✅ CallKit call reported successfully via unified path"
|
|
820
1692
|
)
|
|
821
1693
|
|
|
1694
|
+
callKitManager.markCallKitRegistered(callUUID)
|
|
1695
|
+
self.storePendingVoipPush(payload.dictionaryPayload)
|
|
1696
|
+
|
|
822
1697
|
// CRITICAL: Store the CallKit UUID for the React Native CallKitCoordinator
|
|
823
1698
|
// This allows the coordinator to find the existing CallKit call instead of creating a duplicate
|
|
824
1699
|
UserDefaults.standard.set(callUUID.uuidString, forKey: "@pending_callkit_uuid")
|