@telnyx/react-voice-commons-sdk 0.4.2 → 1.0.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # CHANGELOG.md
2
2
 
3
+ ## [0.4.3] (2026-04-30)
4
+
5
+ ### Bug Fixing
6
+
7
+ - **VoIP push always rebuilds the `TelnyxRTC` instance.** `SessionManager.handlePushNotification` now unconditionally disposes any prior client and runs `_connect()` afresh, so the new socket's URL bakes in THIS push's `voice_sdk_id`. Reusing a prior connection would cause the gateway to route this push's INVITE to whichever client announced with the new `voice_sdk_id` (i.e. a different connection), leaving us listening on the wrong socket until CallKit timed out.
8
+ - **Dropped the `loginFromStoredConfig()` fallback inside `TelnyxVoipClient.handlePushNotification`.** When SessionManager's push-driven `_connect()` didn't immediately reach `CONNECTED`, the fallback would create a second TelnyxRTC instance with no `voice_sdk_id` awareness. The gateway then `punt`-ed one of the parallel sessions, dropping the active call. SessionManager already loads stored credentials internally for the cold-launch path; the redundant fallback only created races.
9
+
10
+ ### Dependencies
11
+
12
+ - Now requires `@telnyx/react-native-voice-sdk >= 0.4.5`, which removes the auto-reconnect on socket error/close that was triggering the multi-instance race. See the [voice-sdk 0.4.5 changelog](../package/CHANGELOG.md#045-2026-04-30) for details.
13
+
3
14
  ## [0.4.2] (2026-04-27)
4
15
 
5
16
  ### Bug Fixing
@@ -1,5 +1,6 @@
1
1
  package com.telnyx.react_voice_commons
2
2
 
3
+ import android.content.Intent
3
4
  import android.util.Log
4
5
  import com.google.firebase.messaging.FirebaseMessagingService
5
6
  import com.google.firebase.messaging.RemoteMessage
@@ -86,7 +87,20 @@ open class TelnyxFirebaseMessagingService : FirebaseMessagingService() {
86
87
 
87
88
  // Show missed call notification (this will hide any existing incoming call notification)
88
89
  val notificationHelper = TelnyxNotificationHelper(this)
89
- notificationHelper.showMissedCallNotification(callerName, callerNumber, callId)
90
+ notificationHelper.hideIncomingCallNotification()
91
+ notificationHelper.hideOngoingCallNotification()
92
+
93
+ try {
94
+ stopService(Intent(this, CallForegroundService::class.java))
95
+ } catch (e: Exception) {
96
+ Log.e(tag, "Failed to stop CallForegroundService for missed call", e)
97
+ }
98
+
99
+ if (VoicePnManager.getMissedCallNotificationsEnabled(this)) {
100
+ notificationHelper.showMissedCallNotification(callerName, callerNumber, callId)
101
+ } else {
102
+ Log.d(tag, "Missed call notification display disabled")
103
+ }
90
104
 
91
105
  Log.d(tag, "Telnyx missed call notification processed successfully")
92
106
  } catch (e: Exception) {
@@ -176,4 +190,4 @@ open class TelnyxFirebaseMessagingService : FirebaseMessagingService() {
176
190
  protected open fun handleTokenRefresh(token: String) {
177
191
  Log.d(tag, "Default token refresh handler - override this method if needed")
178
192
  }
179
- }
193
+ }
@@ -1,6 +1,8 @@
1
1
  package com.telnyx.react_voice_commons
2
2
 
3
3
  import android.content.Intent
4
+ import android.content.Context
5
+ import android.media.AudioManager
4
6
  import android.os.Build
5
7
  import android.util.Log
6
8
  import com.facebook.react.bridge.ReactApplicationContext
@@ -222,6 +224,58 @@ class VoicePnBridgeModule(reactContext: ReactApplicationContext) : ReactContextB
222
224
  promise.reject("HIDE_INCOMING_NOTIFICATION_ERROR", e.message, e)
223
225
  }
224
226
  }
227
+
228
+ @ReactMethod
229
+ fun setMissedCallNotificationsEnabled(enabled: Boolean, promise: Promise) {
230
+ try {
231
+ val result = VoicePnManager.setMissedCallNotificationsEnabled(
232
+ reactApplicationContext,
233
+ enabled
234
+ )
235
+ promise.resolve(result)
236
+ } catch (e: Exception) {
237
+ Log.e(TAG, "Error setting missed call notifications", e)
238
+ promise.reject("SET_MISSED_CALL_NOTIFICATIONS_ERROR", e.message, e)
239
+ }
240
+ }
241
+
242
+ @ReactMethod
243
+ fun getMissedCallNotificationsEnabled(promise: Promise) {
244
+ try {
245
+ promise.resolve(VoicePnManager.getMissedCallNotificationsEnabled(reactApplicationContext))
246
+ } catch (e: Exception) {
247
+ Log.e(TAG, "Error getting missed call notifications", e)
248
+ promise.reject("GET_MISSED_CALL_NOTIFICATIONS_ERROR", e.message, e)
249
+ }
250
+ }
251
+
252
+ @ReactMethod
253
+ fun setSpeakerEnabled(enabled: Boolean, promise: Promise) {
254
+ try {
255
+ val audioManager = reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
256
+ // The active call flow owns AudioManager.mode. This bridge only requests the speaker route.
257
+ // Connected Bluetooth or wired accessories may still take precedence over speakerphone.
258
+ audioManager.isSpeakerphoneOn = enabled
259
+ val actualEnabled = audioManager.isSpeakerphoneOn
260
+
261
+ Log.d(TAG, "setSpeakerEnabled called from React Native: requested=$enabled actual=$actualEnabled")
262
+ promise.resolve(actualEnabled)
263
+ } catch (e: Exception) {
264
+ Log.e(TAG, "Error setting speaker route", e)
265
+ promise.reject("SET_SPEAKER_ENABLED_ERROR", e.message, e)
266
+ }
267
+ }
268
+
269
+ @ReactMethod
270
+ fun isSpeakerEnabled(promise: Promise) {
271
+ try {
272
+ val audioManager = reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
273
+ promise.resolve(audioManager.isSpeakerphoneOn)
274
+ } catch (e: Exception) {
275
+ Log.e(TAG, "Error getting speaker route", e)
276
+ promise.reject("GET_SPEAKER_ENABLED_ERROR", e.message, e)
277
+ }
278
+ }
225
279
 
226
280
  /**
227
281
  * Emit call action event to React Native (for immediate notification)
@@ -245,4 +299,4 @@ class VoicePnBridgeModule(reactContext: ReactApplicationContext) : ReactContextB
245
299
  Log.e(TAG, "Error emitting call action event", e)
246
300
  }
247
301
  }
248
- }
302
+ }
@@ -8,6 +8,7 @@ object VoicePnManager {
8
8
 
9
9
  private const val TAG = "VoicePnManager"
10
10
  private const val PREFS_NAME = "telnyx_voice_prefs"
11
+ private const val MISSED_CALL_NOTIFICATIONS_ENABLED = "telnyx_enable_missed_call_notifications"
11
12
 
12
13
  private fun getSharedPreferences(context: Context): SharedPreferences {
13
14
  return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -78,6 +79,21 @@ object VoicePnManager {
78
79
  return true
79
80
  }
80
81
 
82
+ fun setMissedCallNotificationsEnabled(context: Context, enabled: Boolean): Boolean {
83
+ Log.d(TAG, "setMissedCallNotificationsEnabled called with: $enabled")
84
+ getSharedPreferences(context)
85
+ .edit()
86
+ .putBoolean(MISSED_CALL_NOTIFICATIONS_ENABLED, enabled)
87
+ .apply()
88
+ return true
89
+ }
90
+
91
+ fun getMissedCallNotificationsEnabled(context: Context): Boolean {
92
+ Log.d(TAG, "getMissedCallNotificationsEnabled called")
93
+ return getSharedPreferences(context)
94
+ .getBoolean(MISSED_CALL_NOTIFICATIONS_ENABLED, true)
95
+ }
96
+
81
97
  // PnModule functions
82
98
  fun setPendingPushAction(context: Context, action: String, metadata: String) {
83
99
  Log.d(TAG, "setPendingPushAction called with action: $action")
@@ -151,4 +167,4 @@ object VoicePnManager {
151
167
  editor.apply()
152
168
  return true
153
169
  }
154
- }
170
+ }
@@ -6,6 +6,7 @@ import React
6
6
  import AVFoundation
7
7
  import UIKit
8
8
  import PushKit
9
+ import UserNotifications
9
10
  import WebRTC
10
11
 
11
12
  @objc(CallKitBridge)
@@ -291,6 +292,210 @@ import React
291
292
  }
292
293
  }
293
294
 
295
+ // MARK: - Missed Call
296
+ enum TelnyxVoiceUserDefaultsKey {
297
+ static let missedCallNotificationsEnabled = "telnyx_enable_missed_call_notifications"
298
+ }
299
+
300
+ private enum TelnyxMissedCallPush {
301
+ static let processedMissedCallIdsKey = "telnyx_processed_missed_call_ids"
302
+ private static let missedCallMarkerKeys: Set<String> = [
303
+ "is_missed_call",
304
+ "message",
305
+ "missed",
306
+ "missed_call",
307
+ "notification_type",
308
+ "push_type",
309
+ "type",
310
+ ]
311
+ private static let affirmativeMissedCallValues: Set<String> = [
312
+ "1",
313
+ "missed",
314
+ "missed call",
315
+ "true",
316
+ "yes",
317
+ ]
318
+
319
+ static func isMissedCallPayload(_ payload: [AnyHashable: Any]) -> Bool {
320
+ return containsMissedCallMarker(payload)
321
+ }
322
+
323
+ static func stableIdentifier(from payload: [AnyHashable: Any]) -> String {
324
+ if let callId = callId(from: payload), !callId.isEmpty {
325
+ return callId
326
+ }
327
+
328
+ return "payload-\(hashHex(canonicalString(from: payload)))"
329
+ }
330
+
331
+ static func uuid(from identifier: String) -> UUID {
332
+ if let uuid = UUID(uuidString: identifier) {
333
+ return uuid
334
+ }
335
+
336
+ let firstHash = hashHex(identifier)
337
+ let secondHash = hashHex("telnyx-\(identifier)")
338
+ let uuidString =
339
+ "\(firstHash.prefix(8))-\(firstHash.dropFirst(8).prefix(4))-\(firstHash.dropFirst(12).prefix(4))-\(secondHash.prefix(4))-\(secondHash.dropFirst(4).prefix(12))"
340
+
341
+ return UUID(uuidString: uuidString) ?? UUID()
342
+ }
343
+
344
+ static func callId(from payload: [AnyHashable: Any]) -> String? {
345
+ return stringValue(for: ["call_id", "callId"], in: payload)
346
+ }
347
+
348
+ static func callerName(from payload: [AnyHashable: Any]) -> String {
349
+ return stringValue(for: ["caller_name", "caller", "from"], in: payload)
350
+ ?? callerNumber(from: payload)
351
+ }
352
+
353
+ static func callerNumber(from payload: [AnyHashable: Any]) -> String {
354
+ return stringValue(for: ["caller_number", "caller", "from"], in: payload)
355
+ ?? "Unknown Caller"
356
+ }
357
+
358
+ static func missedCallNotificationsEnabled() -> Bool {
359
+ // bool(forKey:) returns false for both "unset" and "false"; default to enabled
360
+ // unless the app explicitly stores a preference.
361
+ guard
362
+ UserDefaults.standard.object(
363
+ forKey: TelnyxVoiceUserDefaultsKey.missedCallNotificationsEnabled) != nil
364
+ else {
365
+ return true
366
+ }
367
+ return UserDefaults.standard.bool(
368
+ forKey: TelnyxVoiceUserDefaultsKey.missedCallNotificationsEnabled)
369
+ }
370
+
371
+ private static func stringValue(for keys: [String], in payload: [AnyHashable: Any]) -> String? {
372
+ if let metadata = payload["metadata"] as? [AnyHashable: Any],
373
+ let value = firstStringValue(for: keys, in: metadata)
374
+ {
375
+ return value
376
+ }
377
+
378
+ if let metadata = payload["metadata"] as? [String: Any],
379
+ let value = firstStringValue(for: keys, in: metadata)
380
+ {
381
+ return value
382
+ }
383
+
384
+ return firstStringValue(for: keys, in: payload)
385
+ }
386
+
387
+ private static func firstStringValue(for keys: [String], in dictionary: [AnyHashable: Any])
388
+ -> String?
389
+ {
390
+ for key in keys {
391
+ if let value = dictionary[key] as? String, !value.isEmpty {
392
+ return value
393
+ }
394
+ }
395
+ return nil
396
+ }
397
+
398
+ private static func firstStringValue(for keys: [String], in dictionary: [String: Any])
399
+ -> String?
400
+ {
401
+ for key in keys {
402
+ if let value = dictionary[key] as? String, !value.isEmpty {
403
+ return value
404
+ }
405
+ }
406
+ return nil
407
+ }
408
+
409
+ private static func containsMissedCallMarker(_ value: Any, key: String? = nil) -> Bool {
410
+ if let key = key, missedCallMarkerKeys.contains(normalizedPayloadKey(key)) {
411
+ if let boolValue = value as? Bool {
412
+ return boolValue
413
+ }
414
+
415
+ if let stringValue = value as? String {
416
+ return affirmativeMissedCallValues.contains(normalizedMissedCallValue(stringValue))
417
+ }
418
+ }
419
+
420
+ if let stringValue = value as? String {
421
+ let normalized = normalizedMissedCallValue(stringValue)
422
+ if normalized == "missed call" || normalized == "missed" {
423
+ return true
424
+ }
425
+ }
426
+
427
+ if let dictionary = value as? [AnyHashable: Any] {
428
+ return dictionary.contains { rawKey, nestedValue in
429
+ containsMissedCallMarker(nestedValue, key: String(describing: rawKey))
430
+ }
431
+ }
432
+
433
+ if let dictionary = value as? [String: Any] {
434
+ return dictionary.contains { key, nestedValue in
435
+ containsMissedCallMarker(nestedValue, key: key)
436
+ }
437
+ }
438
+
439
+ if let array = value as? [Any] {
440
+ return array.contains { containsMissedCallMarker($0) }
441
+ }
442
+
443
+ return false
444
+ }
445
+
446
+ private static func normalizedPayloadKey(_ key: String) -> String {
447
+ return key
448
+ .lowercased()
449
+ .replacingOccurrences(of: "-", with: "_")
450
+ .replacingOccurrences(of: " ", with: "_")
451
+ .trimmingCharacters(in: .whitespacesAndNewlines)
452
+ }
453
+
454
+ private static func normalizedMissedCallValue(_ value: String) -> String {
455
+ return value
456
+ .lowercased()
457
+ .replacingOccurrences(of: "_", with: " ")
458
+ .replacingOccurrences(of: "-", with: " ")
459
+ .replacingOccurrences(of: "!", with: "")
460
+ .trimmingCharacters(in: .whitespacesAndNewlines)
461
+ }
462
+
463
+ private static func canonicalString(from value: Any) -> String {
464
+ if let dictionary = value as? [AnyHashable: Any] {
465
+ return dictionary
466
+ .map { (String(describing: $0.key), $0.value) }
467
+ .sorted { $0.0 < $1.0 }
468
+ .map { "\($0):\(canonicalString(from: $1))" }
469
+ .joined(separator: "|")
470
+ }
471
+
472
+ if let dictionary = value as? [String: Any] {
473
+ return dictionary
474
+ .sorted { $0.key < $1.key }
475
+ .map { "\($0):\(canonicalString(from: $1))" }
476
+ .joined(separator: "|")
477
+ }
478
+
479
+ if let array = value as? [Any] {
480
+ return array.map { canonicalString(from: $0) }.joined(separator: ",")
481
+ }
482
+
483
+ return String(describing: value)
484
+ }
485
+
486
+ private static func hashHex(_ value: String) -> String {
487
+ let prime: UInt64 = 1_099_511_628_211
488
+ var hash: UInt64 = 14_695_981_039_346_656_037
489
+
490
+ for byte in value.utf8 {
491
+ hash ^= UInt64(byte)
492
+ hash = hash &* prime
493
+ }
494
+
495
+ return String(format: "%016llx", hash)
496
+ }
497
+ }
498
+
294
499
  // MARK: - TelnyxCallKitManager
295
500
  /// TelnyxCallKitManager - Automatically handles CallKit integration for Telnyx Voice
296
501
  /// This class sets up CallKit and VoIP push notifications automatically when the app starts
@@ -404,6 +609,216 @@ import React
404
609
  setupCallKit()
405
610
  }
406
611
  }
612
+
613
+ @discardableResult
614
+ public func handleMissedCallPushIfNeeded(
615
+ _ payload: [AnyHashable: Any],
616
+ completion: (() -> Void)? = nil
617
+ ) -> Bool {
618
+ guard TelnyxMissedCallPush.isMissedCallPayload(payload) else {
619
+ return false
620
+ }
621
+
622
+ NSLog("TelnyxVoice: Missed call VoIP push received: \(payload)")
623
+ setupSynchronously()
624
+
625
+ let callId = TelnyxMissedCallPush.callId(from: payload)
626
+ 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
+ let callUUID = TelnyxMissedCallPush.uuid(from: missedCallId)
634
+ let callerName = TelnyxMissedCallPush.callerName(from: payload)
635
+ let callerNumber = TelnyxMissedCallPush.callerNumber(from: payload)
636
+
637
+ let finishMissedCall = {
638
+ self.callKitProvider?.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
639
+ self.activeCalls.removeValue(forKey: callUUID)
640
+
641
+ if self.pendingAnswerAction?.callUUID == callUUID {
642
+ self.pendingAnswerAction?.fail()
643
+ self.pendingAnswerAction = nil
644
+ }
645
+
646
+ NSLog("TelnyxVoice: Ended stale CallKit call for missed call UUID: \(callUUID)")
647
+ self.clearPendingPushData(for: callId)
648
+ self.showMissedCallNotification(
649
+ callerName: callerName,
650
+ callerNumber: callerNumber,
651
+ missedCallId: missedCallId
652
+ )
653
+ self.markMissedCallProcessed(id: missedCallId)
654
+ completion?()
655
+ }
656
+
657
+ if let activeCall = activeCalls[callUUID] {
658
+ guard activeCall["source"] as? String == "missed_call_push" else {
659
+ NSLog("TelnyxVoice: Ignoring missed call push because UUID belongs to an active call: \(callUUID)")
660
+ markMissedCallProcessed(id: missedCallId)
661
+ completion?()
662
+ return true
663
+ }
664
+
665
+ finishMissedCall()
666
+ return true
667
+ }
668
+
669
+ activeCalls[callUUID] = [
670
+ "caller": callerName,
671
+ "handle": callerNumber,
672
+ "payload": payload,
673
+ "uuid": callUUID.uuidString,
674
+ "direction": "incoming",
675
+ "source": "missed_call_push",
676
+ ]
677
+
678
+ let handle = CXHandle(type: .phoneNumber, value: callerNumber)
679
+ let callUpdate = CXCallUpdate()
680
+ callUpdate.remoteHandle = handle
681
+ callUpdate.hasVideo = false
682
+ callUpdate.localizedCallerName = callerName
683
+
684
+ callKitProvider?.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
685
+ if let error = error {
686
+ NSLog("TelnyxVoice: Missed call CallKit report failed: \(error.localizedDescription)")
687
+ self.activeCalls.removeValue(forKey: callUUID)
688
+ self.clearPendingPushData(for: callId)
689
+ completion?()
690
+ return
691
+ }
692
+
693
+ finishMissedCall()
694
+ }
695
+
696
+ return true
697
+ }
698
+
699
+ private func clearPendingPushData(for callId: String?) {
700
+ if hasPendingPushData(forDifferentCallThan: callId) {
701
+ NSLog("TelnyxVoice: Skipping pending push cleanup for missed call; pending data belongs to a different call")
702
+ return
703
+ }
704
+
705
+ UserDefaults.standard.removeObject(forKey: "pending_push_action")
706
+ UserDefaults.standard.removeObject(forKey: "pending_push_metadata")
707
+ UserDefaults.standard.removeObject(forKey: "pending_voip_push")
708
+ UserDefaults.standard.removeObject(forKey: "pending_voip_action")
709
+ UserDefaults.standard.removeObject(forKey: "@pending_callkit_uuid")
710
+ UserDefaults.standard.synchronize()
711
+ }
712
+
713
+ private func hasPendingPushData(forDifferentCallThan missedCallId: String?) -> Bool {
714
+ guard let missedCallId = missedCallId, !missedCallId.isEmpty else {
715
+ return false
716
+ }
717
+
718
+ let pendingIds = [
719
+ pendingCallId(fromJsonForKey: "pending_push_metadata"),
720
+ pendingCallId(fromJsonForKey: "pending_voip_push"),
721
+ UserDefaults.standard.string(forKey: "@pending_callkit_uuid"),
722
+ ].compactMap { $0 }
723
+
724
+ return pendingIds.contains { !$0.isEmpty && $0 != missedCallId }
725
+ }
726
+
727
+ private func pendingCallId(fromJsonForKey key: String) -> String? {
728
+ guard let jsonString = UserDefaults.standard.string(forKey: key),
729
+ let data = jsonString.data(using: .utf8),
730
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
731
+ else {
732
+ return nil
733
+ }
734
+
735
+ if let directCallId = callId(from: json) {
736
+ return directCallId
737
+ }
738
+
739
+ if let payload = json["payload"] as? [String: Any] {
740
+ return callId(from: payload)
741
+ }
742
+
743
+ return nil
744
+ }
745
+
746
+ private func callId(from dictionary: [String: Any]) -> String? {
747
+ for key in ["call_id", "callId", "telnyx_call_id"] {
748
+ if let value = dictionary[key] as? String, !value.isEmpty {
749
+ return value
750
+ }
751
+ }
752
+
753
+ if let metadata = dictionary["metadata"] as? [String: Any] {
754
+ return callId(from: metadata)
755
+ }
756
+
757
+ return nil
758
+ }
759
+
760
+ private func hasProcessedMissedCall(id: String) -> Bool {
761
+ return processedMissedCallIds().contains(id)
762
+ }
763
+
764
+ private func markMissedCallProcessed(id: String) {
765
+ var ids = processedMissedCallIds().filter { $0 != id }
766
+ ids.append(id)
767
+ UserDefaults.standard.set(Array(ids.suffix(50)), forKey: TelnyxMissedCallPush.processedMissedCallIdsKey)
768
+ UserDefaults.standard.synchronize()
769
+ }
770
+
771
+ private func processedMissedCallIds() -> [String] {
772
+ return UserDefaults.standard.stringArray(
773
+ forKey: TelnyxMissedCallPush.processedMissedCallIdsKey) ?? []
774
+ }
775
+
776
+ private func showMissedCallNotification(
777
+ callerName: String, callerNumber: String, missedCallId: String
778
+ ) {
779
+ guard TelnyxMissedCallPush.missedCallNotificationsEnabled() else {
780
+ NSLog("TelnyxVoice: Missed call local notification disabled")
781
+ return
782
+ }
783
+
784
+ let content = UNMutableNotificationContent()
785
+ content.title = "Missed Call"
786
+ content.body = "You missed a call from \(callerName)"
787
+ content.sound = .default
788
+ content.userInfo = [
789
+ "call_id": missedCallId,
790
+ "caller_name": callerName,
791
+ "caller_number": callerNumber,
792
+ ]
793
+
794
+ let identifier = "telnyx.missed-call.\(missedCallId)"
795
+ let request = UNNotificationRequest(
796
+ identifier: identifier,
797
+ content: content,
798
+ trigger: nil
799
+ )
800
+
801
+ let notificationCenter = UNUserNotificationCenter.current()
802
+ notificationCenter.getNotificationSettings { settings in
803
+ guard settings.authorizationStatus == .authorized
804
+ || settings.authorizationStatus == .provisional
805
+ || settings.authorizationStatus == .ephemeral
806
+ else {
807
+ NSLog(
808
+ "TelnyxVoice: Missed call notification not shown; notification authorization status: \(settings.authorizationStatus.rawValue)"
809
+ )
810
+ return
811
+ }
812
+
813
+ notificationCenter.add(request) { error in
814
+ if let error = error {
815
+ NSLog("TelnyxVoice: Failed to show missed call notification: \(error)")
816
+ } else {
817
+ NSLog("TelnyxVoice: Missed call notification scheduled")
818
+ }
819
+ }
820
+ }
821
+ }
407
822
  }
408
823
 
409
824
  // MARK: - PKPushRegistryDelegate
@@ -458,6 +873,10 @@ import React
458
873
  ) {
459
874
  NSLog("TelnyxVoice: Received VoIP push notification: \(payload.dictionaryPayload)")
460
875
 
876
+ if handleMissedCallPushIfNeeded(payload.dictionaryPayload, completion: completion) {
877
+ return
878
+ }
879
+
461
880
  // Store the VoIP push data for VoicePnBridge (same format as AppDelegate was using)
462
881
  do {
463
882
  let jsonData = try JSONSerialization.data(withJSONObject: payload.dictionaryPayload)
@@ -698,6 +1117,13 @@ import React
698
1117
  NSLog("[TelnyxVoipPushHandler] VoIP push received for type: \(type.rawValue)")
699
1118
  NSLog("[TelnyxVoipPushHandler] VoIP payload: \(payload.dictionaryPayload)")
700
1119
 
1120
+ if TelnyxCallKitManager.shared.handleMissedCallPushIfNeeded(
1121
+ payload.dictionaryPayload,
1122
+ completion: completion
1123
+ ) {
1124
+ return
1125
+ }
1126
+
701
1127
  // Configure audio session early for VoIP call
702
1128
  let audioSession = AVAudioSession.sharedInstance()
703
1129
  do {
@@ -28,4 +28,18 @@ RCT_EXTERN_METHOD(getPendingVoipAction:(RCTPromiseResolveBlock)resolve
28
28
  RCT_EXTERN_METHOD(clearPendingVoipAction:(RCTPromiseResolveBlock)resolve
29
29
  rejecter:(RCTPromiseRejectBlock)reject)
30
30
 
31
- @end
31
+ RCT_EXTERN_METHOD(setMissedCallNotificationsEnabled:(BOOL)enabled
32
+ resolver:(RCTPromiseResolveBlock)resolve
33
+ rejecter:(RCTPromiseRejectBlock)reject)
34
+
35
+ RCT_EXTERN_METHOD(getMissedCallNotificationsEnabled:(RCTPromiseResolveBlock)resolve
36
+ rejecter:(RCTPromiseRejectBlock)reject)
37
+
38
+ RCT_EXTERN_METHOD(setSpeakerEnabled:(BOOL)enabled
39
+ resolver:(RCTPromiseResolveBlock)resolve
40
+ rejecter:(RCTPromiseRejectBlock)reject)
41
+
42
+ RCT_EXTERN_METHOD(isSpeakerEnabled:(RCTPromiseResolveBlock)resolve
43
+ rejecter:(RCTPromiseRejectBlock)reject)
44
+
45
+ @end