@telnyx/react-voice-commons-sdk 0.1.7 → 0.1.8-beta.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 +11 -0
- package/README.md +35 -5
- package/ios/CallKitBridge.swift +92 -55
- package/lib/callkit/callkit-coordinator.js +56 -22
- package/lib/telnyx-voice-app.js +17 -5
- package/package.json +1 -1
- package/src/callkit/callkit-coordinator.ts +62 -24
- package/src/telnyx-voice-app.tsx +16 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# CHANGELOG.md
|
|
2
2
|
|
|
3
|
+
## [0.1.8-beta.1](https://github.com/team-telnyx/react-native-voice-commons/releases/tag/0.1.8-beta.1) (2026-03-04)
|
|
4
|
+
|
|
5
|
+
### Bug Fixing
|
|
6
|
+
|
|
7
|
+
• Fixed duplicate CXProvider overwrite causing `CXEndCallAction` error 4 (unknownCallUUID) — guard `setupCallKit()` to prevent async `setupAutomatically()` from overwriting the provider created by `setupSynchronously()` during VoIP push handling
|
|
8
|
+
• Fixed intermittent audio loss on push notification calls — defer `CXAnswerCallAction.fulfill()` until `reportCallConnected()` when the WebRTC peer connection is ready
|
|
9
|
+
• Aligned iOS audio session handling with native Telnyx iOS SDK pattern (`RTCAudioSessionConfiguration.webRTC()` with `lockForConfiguration`/`unlockForConfiguration`)
|
|
10
|
+
• Fixed endCall not dismissing CallKit UI — check `endCall()` return value and fallback to `reportCallEnded()` when CXEndCallAction fails
|
|
11
|
+
• Fixed push notification answer crash when voipClient is not yet initialized — defer to `checkForInitialPushNotification()` instead of failing the call
|
|
12
|
+
• Added `voipClient.queueAnswerFromCallKit()` call when handling push notification answer for auto-answer on INVITE arrival
|
|
13
|
+
|
|
3
14
|
## [0.1.7](https://github.com/team-telnyx/react-native-voice-commons/releases/tag/0.1.7) (2026-02-20)
|
|
4
15
|
|
|
5
16
|
### Enhancement
|
package/README.md
CHANGED
|
@@ -32,15 +32,30 @@ The `@telnyx/react-voice-commons-sdk` library provides:
|
|
|
32
32
|
Integrate the library using the `TelnyxVoiceApp` component for automatic lifecycle management:
|
|
33
33
|
|
|
34
34
|
```tsx
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
TelnyxVoiceApp,
|
|
37
|
+
TelnyxVoipClient,
|
|
38
|
+
createTelnyxVoipClient,
|
|
39
|
+
} from '@telnyx/react-voice-commons-sdk';
|
|
36
40
|
|
|
37
|
-
// Create the VoIP client instance
|
|
41
|
+
// Create the VoIP client instance (singleton — safe to call inside a component body)
|
|
38
42
|
const voipClient = createTelnyxVoipClient({
|
|
39
43
|
enableAppStateManagement: true, // Optional: Enable automatic app state management (default: true)
|
|
40
44
|
debug: true, // Optional: Enable debug logging
|
|
41
45
|
});
|
|
42
46
|
|
|
43
47
|
export default function App() {
|
|
48
|
+
// Skip auto-login if the app was launched from a push notification —
|
|
49
|
+
// the SDK handles login internally via the push notification flow.
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
TelnyxVoipClient.isLaunchedFromPushNotification().then((isFromPush) => {
|
|
52
|
+
if (!isFromPush) {
|
|
53
|
+
// Safe to auto-login
|
|
54
|
+
voipClient.loginFromStoredConfig();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
44
59
|
return (
|
|
45
60
|
<TelnyxVoiceApp voipClient={voipClient} enableAutoReconnect={false} debug={true}>
|
|
46
61
|
<YourAppContent />
|
|
@@ -54,10 +69,16 @@ export default function App() {
|
|
|
54
69
|
### 1. VoIP Client Configuration
|
|
55
70
|
|
|
56
71
|
```tsx
|
|
72
|
+
// createTelnyxVoipClient is a singleton — repeated calls return the same instance.
|
|
73
|
+
// This makes it safe to call inside a React component body without re-creating on every render.
|
|
57
74
|
const voipClient = createTelnyxVoipClient({
|
|
58
75
|
enableAppStateManagement: true, // Optional: Enable automatic app state management (default: true)
|
|
59
76
|
debug: true, // Optional: Enable debug logging
|
|
60
77
|
});
|
|
78
|
+
|
|
79
|
+
// If you need to tear down and recreate the client (e.g., on logout):
|
|
80
|
+
import { destroyTelnyxVoipClient } from '@telnyx/react-voice-commons-sdk';
|
|
81
|
+
destroyTelnyxVoipClient(); // Disposes the singleton; next createTelnyxVoipClient() call creates a fresh instance
|
|
61
82
|
```
|
|
62
83
|
|
|
63
84
|
**Configuration Options Explained:**
|
|
@@ -73,6 +94,7 @@ The `TelnyxVoiceApp` component handles:
|
|
|
73
94
|
- Push notification processing from terminated state
|
|
74
95
|
- Login state management with automatic reconnection
|
|
75
96
|
- Background client management for push notifications
|
|
97
|
+
- **Automatic CallKit coordinator wiring** — the `voipClient` is set on the CallKit coordinator on mount, so you don't need to call `setVoipClient()` manually
|
|
76
98
|
|
|
77
99
|
### 3. Reactive State Management
|
|
78
100
|
|
|
@@ -416,9 +438,18 @@ npx expo run:ios
|
|
|
416
438
|
|
|
417
439
|
### Common Integration Issues
|
|
418
440
|
|
|
419
|
-
### Double Login
|
|
441
|
+
### Double Login on Cold-Start
|
|
442
|
+
|
|
443
|
+
When the app is launched from a push notification, the SDK handles login internally. If your app also auto-logs in on mount, both will race and the push flow breaks. Use `isLaunchedFromPushNotification()` to guard your auto-login:
|
|
444
|
+
|
|
445
|
+
```tsx
|
|
446
|
+
const isFromPush = await TelnyxVoipClient.isLaunchedFromPushNotification();
|
|
447
|
+
if (!isFromPush) {
|
|
448
|
+
voipClient.loginFromStoredConfig();
|
|
449
|
+
}
|
|
450
|
+
```
|
|
420
451
|
|
|
421
|
-
|
|
452
|
+
Also ensure you're not calling login methods manually when using `TelnyxVoiceApp` with auto-reconnection enabled.
|
|
422
453
|
|
|
423
454
|
### Background Disconnection
|
|
424
455
|
|
|
@@ -457,7 +488,6 @@ useEffect(() => {
|
|
|
457
488
|
const subscription = voipClient.connectionState$.subscribe(handleStateChange);
|
|
458
489
|
return () => subscription.unsubscribe();
|
|
459
490
|
}, []);
|
|
460
|
-
}, []);
|
|
461
491
|
```
|
|
462
492
|
|
|
463
493
|
## Documentation
|
package/ios/CallKitBridge.swift
CHANGED
|
@@ -205,6 +205,33 @@ import React
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
provider.reportOutgoingCall(with: uuid, connectedAt: Date())
|
|
208
|
+
|
|
209
|
+
// Fulfill deferred CXAnswerCallAction now that peer connection is ready
|
|
210
|
+
if let pendingAction = manager.pendingAnswerAction {
|
|
211
|
+
NSLog("TelnyxVoice: reportCallConnected - fulfilling deferred CXAnswerCallAction")
|
|
212
|
+
pendingAction.fulfill()
|
|
213
|
+
manager.pendingAnswerAction = nil
|
|
214
|
+
} else {
|
|
215
|
+
// Fallback: ensure audio is enabled for non-push or already-fulfilled cases
|
|
216
|
+
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
217
|
+
rtcAudioSession.lockForConfiguration()
|
|
218
|
+
let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
|
|
219
|
+
webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
|
|
220
|
+
do {
|
|
221
|
+
try rtcAudioSession.setConfiguration(webRTCConfig)
|
|
222
|
+
} catch {
|
|
223
|
+
NSLog("TelnyxVoice: reportCallConnected - setConfiguration error: \(error)")
|
|
224
|
+
}
|
|
225
|
+
do {
|
|
226
|
+
try rtcAudioSession.setActive(true)
|
|
227
|
+
} catch {
|
|
228
|
+
NSLog("TelnyxVoice: reportCallConnected - setActive error: \(error)")
|
|
229
|
+
}
|
|
230
|
+
rtcAudioSession.isAudioEnabled = true
|
|
231
|
+
rtcAudioSession.unlockForConfiguration()
|
|
232
|
+
rtcAudioSession.audioSessionDidActivate(AVAudioSession.sharedInstance())
|
|
233
|
+
}
|
|
234
|
+
|
|
208
235
|
resolve(["success": true])
|
|
209
236
|
}
|
|
210
237
|
|
|
@@ -276,6 +303,7 @@ import React
|
|
|
276
303
|
public var callKitProvider: CXProvider?
|
|
277
304
|
public var callKitController: CXCallController?
|
|
278
305
|
public var activeCalls: [UUID: [String: Any]] = [:]
|
|
306
|
+
public var pendingAnswerAction: CXAnswerCallAction?
|
|
279
307
|
|
|
280
308
|
private override init() {
|
|
281
309
|
super.init()
|
|
@@ -327,12 +355,16 @@ import React
|
|
|
327
355
|
}
|
|
328
356
|
|
|
329
357
|
private func setupCallKit() {
|
|
330
|
-
//
|
|
358
|
+
// ALWAYS configure WebRTC audio, even if provider already exists
|
|
331
359
|
RTCAudioSession.sharedInstance().useManualAudio = true
|
|
332
|
-
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
)
|
|
360
|
+
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
|
361
|
+
|
|
362
|
+
// Guard against duplicate provider creation (async setupAutomatically can overwrite
|
|
363
|
+
// the provider created by setupSynchronously for VoIP push)
|
|
364
|
+
guard callKitProvider == nil else {
|
|
365
|
+
NSLog("TelnyxVoice: setupCallKit() - provider already exists, skipping to prevent overwrite")
|
|
366
|
+
return
|
|
367
|
+
}
|
|
336
368
|
|
|
337
369
|
// Use the localizedName from the app's bundle display name or fallback
|
|
338
370
|
let appName =
|
|
@@ -351,11 +383,7 @@ import React
|
|
|
351
383
|
callKitProvider?.setDelegate(self, queue: nil)
|
|
352
384
|
callKitController = CXCallController()
|
|
353
385
|
|
|
354
|
-
NSLog(
|
|
355
|
-
"📞 TelnyxVoice: CallKit provider instance: \(String(describing: callKitProvider))")
|
|
356
|
-
NSLog(
|
|
357
|
-
"📞 TelnyxVoice: CallKit controller instance: \(String(describing: callKitController))"
|
|
358
|
-
)
|
|
386
|
+
NSLog("TelnyxVoice: CallKit provider and controller created")
|
|
359
387
|
}
|
|
360
388
|
|
|
361
389
|
|
|
@@ -519,9 +547,9 @@ import React
|
|
|
519
547
|
callData: activeCalls[action.callUUID])
|
|
520
548
|
}
|
|
521
549
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
550
|
+
// Defer action.fulfill() until reportCallConnected when peer connection is ready
|
|
551
|
+
NSLog("TelnyxVoice: Deferring CXAnswerCallAction.fulfill() until peer connection is ready")
|
|
552
|
+
self.pendingAnswerAction = action
|
|
525
553
|
}
|
|
526
554
|
|
|
527
555
|
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
|
@@ -552,58 +580,67 @@ import React
|
|
|
552
580
|
}
|
|
553
581
|
|
|
554
582
|
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
|
555
|
-
NSLog(
|
|
556
|
-
"🎧🎧🎧 TelnyxVoice: AUDIO SESSION ACTIVATED BY CALLKIT - USER ANSWERED THE CALL! 🎧🎧🎧")
|
|
557
|
-
NSLog("🎧 Provider: \(provider)")
|
|
583
|
+
NSLog("TelnyxVoice: Audio session activated by CallKit")
|
|
558
584
|
|
|
585
|
+
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
559
586
|
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
587
|
+
// Step 1: Configure (matches native iOS SDK setupCorrectAudioConfiguration)
|
|
588
|
+
rtcAudioSession.lockForConfiguration()
|
|
589
|
+
let webRTCConfig = RTCAudioSessionConfiguration.webRTC()
|
|
590
|
+
webRTCConfig.categoryOptions = [.duckOthers, .allowBluetooth]
|
|
565
591
|
do {
|
|
566
|
-
|
|
567
|
-
try audioSession.setCategory(
|
|
568
|
-
.playAndRecord, mode: .voiceChat,
|
|
569
|
-
options: [.allowBluetooth, .allowBluetoothA2DP])
|
|
570
|
-
try audioSession.setActive(true)
|
|
571
|
-
|
|
572
|
-
// Emit audio session activated event to React Native
|
|
573
|
-
CallKitBridge.shared?.emitAudioSessionEvent(
|
|
574
|
-
"AudioSessionActivated",
|
|
575
|
-
data: [
|
|
576
|
-
"category": audioSession.category.rawValue,
|
|
577
|
-
"mode": audioSession.mode.rawValue,
|
|
578
|
-
"isActive": true,
|
|
579
|
-
])
|
|
580
|
-
|
|
581
|
-
NSLog(
|
|
582
|
-
"🎧 SUCCESS: Audio session ACTIVE for VoIP - Category: \(audioSession.category.rawValue), Mode: \(audioSession.mode.rawValue)"
|
|
583
|
-
)
|
|
592
|
+
try rtcAudioSession.setConfiguration(webRTCConfig)
|
|
584
593
|
} catch {
|
|
585
|
-
NSLog("
|
|
594
|
+
NSLog("TelnyxVoice: didActivateAudioSession - setConfiguration error: \(error)")
|
|
595
|
+
}
|
|
596
|
+
rtcAudioSession.unlockForConfiguration()
|
|
586
597
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
598
|
+
// Step 2: Activate (matches native iOS SDK setAudioSessionActive)
|
|
599
|
+
rtcAudioSession.lockForConfiguration()
|
|
600
|
+
do {
|
|
601
|
+
try rtcAudioSession.setActive(true)
|
|
602
|
+
} catch {
|
|
603
|
+
NSLog("TelnyxVoice: didActivateAudioSession - setActive error: \(error)")
|
|
593
604
|
}
|
|
605
|
+
rtcAudioSession.isAudioEnabled = true
|
|
606
|
+
rtcAudioSession.unlockForConfiguration()
|
|
607
|
+
|
|
608
|
+
rtcAudioSession.audioSessionDidActivate(audioSession)
|
|
609
|
+
|
|
610
|
+
// Emit audio session activated event to React Native
|
|
611
|
+
CallKitBridge.shared?.emitAudioSessionEvent(
|
|
612
|
+
"AudioSessionActivated",
|
|
613
|
+
data: [
|
|
614
|
+
"category": audioSession.category.rawValue,
|
|
615
|
+
"mode": audioSession.mode.rawValue,
|
|
616
|
+
"isActive": true,
|
|
617
|
+
])
|
|
594
618
|
}
|
|
595
619
|
|
|
596
620
|
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
|
597
|
-
NSLog("
|
|
598
|
-
NSLog("🔇 Provider: \(provider)")
|
|
599
|
-
NSLog(
|
|
600
|
-
"🔇 Audio session details: active=\(audioSession.isOtherAudioPlaying), category=\(audioSession.category.rawValue), mode=\(audioSession.mode.rawValue)"
|
|
601
|
-
)
|
|
621
|
+
NSLog("TelnyxVoice: Audio session deactivated by CallKit")
|
|
602
622
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
623
|
+
let rtcAudioSession = RTCAudioSession.sharedInstance()
|
|
624
|
+
|
|
625
|
+
// Reset audio config (matches native iOS SDK resetAudioConfiguration)
|
|
626
|
+
let avAudioSession = AVAudioSession.sharedInstance()
|
|
627
|
+
do {
|
|
628
|
+
try avAudioSession.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
|
629
|
+
} catch {
|
|
630
|
+
NSLog("TelnyxVoice: didDeactivateAudioSession - setCategory error: \(error)")
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Deactivate (matches native iOS SDK setAudioSessionActive(false))
|
|
634
|
+
rtcAudioSession.lockForConfiguration()
|
|
635
|
+
do {
|
|
636
|
+
try rtcAudioSession.setActive(false)
|
|
637
|
+
} catch {
|
|
638
|
+
NSLog("TelnyxVoice: didDeactivateAudioSession - setActive(false) error: \(error)")
|
|
639
|
+
}
|
|
640
|
+
rtcAudioSession.isAudioEnabled = false
|
|
641
|
+
rtcAudioSession.unlockForConfiguration()
|
|
642
|
+
|
|
643
|
+
rtcAudioSession.audioSessionDidDeactivate(audioSession)
|
|
607
644
|
|
|
608
645
|
// Emit audio session deactivated event to React Native
|
|
609
646
|
CallKitBridge.shared?.emitAudioSessionEvent(
|
|
@@ -228,21 +228,26 @@ class CallKitCoordinator {
|
|
|
228
228
|
'CallKitCoordinator: Ending call from UI - dismissing CallKit and hanging up WebRTC call',
|
|
229
229
|
callKitUUID
|
|
230
230
|
);
|
|
231
|
-
//
|
|
232
|
-
this.
|
|
231
|
+
// Track this call as ended to prevent duplicate end actions
|
|
232
|
+
this.endedCalls.add(callKitUUID);
|
|
233
233
|
try {
|
|
234
|
-
// End the call in CallKit
|
|
235
|
-
await callkit_1.default.endCall(callKitUUID);
|
|
234
|
+
// End the call in CallKit - endCall returns false on failure (doesn't throw)
|
|
235
|
+
const endCallSuccess = await callkit_1.default.endCall(callKitUUID);
|
|
236
|
+
if (!endCallSuccess) {
|
|
237
|
+
// Fallback: use reportCallEnded to dismiss CallKit UI when endCall fails
|
|
238
|
+
// (e.g., unknownCallUUID error from duplicate CXProvider)
|
|
239
|
+
await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.RemoteEnded);
|
|
240
|
+
}
|
|
241
|
+
this.isCallFromPush = false;
|
|
236
242
|
call.hangup();
|
|
237
|
-
// Clean up the mappings
|
|
238
243
|
this.cleanupCall(callKitUUID);
|
|
239
244
|
return true;
|
|
240
245
|
} catch (error) {
|
|
241
246
|
console.error('CallKitCoordinator: Error ending call from UI', error);
|
|
242
|
-
|
|
247
|
+
this.isCallFromPush = false;
|
|
248
|
+
call.hangup();
|
|
249
|
+
this.cleanupCall(callKitUUID);
|
|
243
250
|
return false;
|
|
244
|
-
} finally {
|
|
245
|
-
this.processingCalls.delete(callKitUUID);
|
|
246
251
|
}
|
|
247
252
|
}
|
|
248
253
|
/**
|
|
@@ -303,10 +308,21 @@ class CallKitCoordinator {
|
|
|
303
308
|
} else {
|
|
304
309
|
console.log('CallKitCoordinator: Outgoing call, skipping answer and CONNECTING state');
|
|
305
310
|
}
|
|
311
|
+
// Clear push data now that answer action is fulfilled
|
|
312
|
+
try {
|
|
313
|
+
await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
|
|
314
|
+
console.log('CallKitCoordinator: Cleared pending VoIP push after answer fulfilled');
|
|
315
|
+
} catch (clearErr) {
|
|
316
|
+
console.error('CallKitCoordinator: Error clearing push data after answer:', clearErr);
|
|
317
|
+
}
|
|
306
318
|
} catch (error) {
|
|
307
319
|
console.error('CallKitCoordinator: Error processing CallKit answer', error);
|
|
308
320
|
await callkit_1.default.reportCallEnded(callKitUUID, callkit_1.CallEndReason.Failed);
|
|
309
321
|
this.cleanupCall(callKitUUID);
|
|
322
|
+
// Clear push data even on error to prevent stale state
|
|
323
|
+
try {
|
|
324
|
+
await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
|
|
325
|
+
} catch (_) {}
|
|
310
326
|
} finally {
|
|
311
327
|
this.processingCalls.delete(callKitUUID);
|
|
312
328
|
}
|
|
@@ -352,6 +368,11 @@ class CallKitCoordinator {
|
|
|
352
368
|
} finally {
|
|
353
369
|
this.processingCalls.delete(callKitUUID);
|
|
354
370
|
this.cleanupCall(callKitUUID);
|
|
371
|
+
// Clear push data now that end action is fulfilled
|
|
372
|
+
try {
|
|
373
|
+
await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
|
|
374
|
+
console.log('CallKitCoordinator: Cleared pending VoIP push after end fulfilled');
|
|
375
|
+
} catch (_) {}
|
|
355
376
|
// Check if app is in background and no more calls - disconnect client
|
|
356
377
|
await this.checkBackgroundDisconnection();
|
|
357
378
|
}
|
|
@@ -456,18 +477,22 @@ class CallKitCoordinator {
|
|
|
456
477
|
// Set auto-answer flag so when the WebRTC call comes in, it will be answered automatically
|
|
457
478
|
this.shouldAutoAnswerNextCall = true;
|
|
458
479
|
console.log('CallKitCoordinator: ✅ Set auto-answer flag for next incoming call');
|
|
459
|
-
//
|
|
480
|
+
// Try to get VoIP client - it may not be wired yet if user answered
|
|
481
|
+
// from CallKit before React Native finished initializing
|
|
460
482
|
const voipClient = this.getSDKClient();
|
|
461
483
|
if (!voipClient) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
)
|
|
465
|
-
|
|
466
|
-
|
|
484
|
+
// voipClient not ready yet - DON'T fail the call.
|
|
485
|
+
// shouldAutoAnswerNextCall is already set to true above.
|
|
486
|
+
// checkForInitialPushNotification() will run after setVoipClient()
|
|
487
|
+
// and will find the push data still intact, call handleCallKitPushReceived()
|
|
488
|
+
// which checks shouldAutoAnswerNextCall and queues the auto-answer.
|
|
467
489
|
return;
|
|
468
490
|
}
|
|
491
|
+
// voipClient is available - queue the answer action on the TelnyxRTC client
|
|
492
|
+
// so when the INVITE arrives after WebSocket login, processInvite() sees
|
|
493
|
+
// pendingAnswerAction=true and auto-answers the call.
|
|
494
|
+
voipClient.queueAnswerFromCallKit();
|
|
469
495
|
// Get the real push data that was stored by the VoIP push handler
|
|
470
|
-
console.log('CallKitCoordinator: 🔍 Getting real push data from VoicePnBridge...');
|
|
471
496
|
let realPushData = null;
|
|
472
497
|
try {
|
|
473
498
|
const pendingPushJson = await voice_pn_bridge_1.VoicePnBridge.getPendingVoipPush();
|
|
@@ -506,6 +531,11 @@ class CallKitCoordinator {
|
|
|
506
531
|
// Set the pending push action to be handled when app comes to foreground
|
|
507
532
|
await voice_pn_bridge_1.VoicePnBridge.setPendingPushAction(pushAction, pushMetadata);
|
|
508
533
|
console.log('CallKitCoordinator: ✅ Set pending push action');
|
|
534
|
+
// Clear push data now that push notification answer is handled
|
|
535
|
+
try {
|
|
536
|
+
await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
|
|
537
|
+
console.log('CallKitCoordinator: Cleared pending VoIP push after push answer handled');
|
|
538
|
+
} catch (_) {}
|
|
509
539
|
return;
|
|
510
540
|
}
|
|
511
541
|
// For other platforms (shouldn't happen on iOS)
|
|
@@ -533,6 +563,11 @@ class CallKitCoordinator {
|
|
|
533
563
|
this.voipClient.queueEndFromCallKit();
|
|
534
564
|
// Clean up push notification state
|
|
535
565
|
await this.cleanupPushNotificationState();
|
|
566
|
+
// Clear push data now that rejection is handled
|
|
567
|
+
try {
|
|
568
|
+
await voice_pn_bridge_1.VoicePnBridge.clearPendingVoipPush();
|
|
569
|
+
console.log('CallKitCoordinator: Cleared pending VoIP push after rejection handled');
|
|
570
|
+
} catch (_) {}
|
|
536
571
|
console.log('CallKitCoordinator: 🎯 Push notification rejection handling complete');
|
|
537
572
|
return;
|
|
538
573
|
}
|
|
@@ -561,16 +596,11 @@ class CallKitCoordinator {
|
|
|
561
596
|
if (!this.connectedCalls.has(callKitUUID)) {
|
|
562
597
|
console.log('CallKitCoordinator: WebRTC call active - reporting connected to CallKit');
|
|
563
598
|
try {
|
|
564
|
-
// Report as connected (CallKit call already answered in UI flow)
|
|
565
599
|
await callkit_1.default.reportCallConnected(callKitUUID);
|
|
566
|
-
console.log(
|
|
567
|
-
'CallKitCoordinator: Call reported as connected to CallKit ',
|
|
568
|
-
callKitUUID
|
|
569
|
-
);
|
|
570
|
-
this.connectedCalls.add(callKitUUID);
|
|
571
600
|
} catch (error) {
|
|
572
601
|
console.error('CallKitCoordinator: Error reporting call connected:', error);
|
|
573
602
|
}
|
|
603
|
+
this.connectedCalls.add(callKitUUID);
|
|
574
604
|
}
|
|
575
605
|
break;
|
|
576
606
|
case 'ended':
|
|
@@ -738,7 +768,11 @@ class CallKitCoordinator {
|
|
|
738
768
|
* This helps prevent premature flag resets during CallKit operations
|
|
739
769
|
*/
|
|
740
770
|
hasProcessingCalls() {
|
|
741
|
-
return this
|
|
771
|
+
// Also return true when isCallFromPush is set — this prevents the
|
|
772
|
+
// calls$ subscription in TelnyxVoiceApp from resetting protection flags
|
|
773
|
+
// (isHandlingForegroundCall, backgroundDetectorIgnore) before the WebRTC
|
|
774
|
+
// call arrives during push notification handling.
|
|
775
|
+
return this.processingCalls.size > 0 || this.isCallFromPush;
|
|
742
776
|
}
|
|
743
777
|
/**
|
|
744
778
|
* Check if there's currently a call from push notification being processed
|
package/lib/telnyx-voice-app.js
CHANGED
|
@@ -274,8 +274,14 @@ const TelnyxVoiceAppComponent = ({
|
|
|
274
274
|
return null;
|
|
275
275
|
}
|
|
276
276
|
log('Found pending VoIP push data:', voipPayload);
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
// Do NOT clear push data here. Let it persist until the answer/end action
|
|
278
|
+
// is fulfilled in the CallKit coordinator. This prevents a race condition in
|
|
279
|
+
// Expo apps where the RN bridge mounts immediately on push notification —
|
|
280
|
+
// the push data would be consumed and cleared before the user answers,
|
|
281
|
+
// leaving the coordinator with nothing to work with.
|
|
282
|
+
// For non-Expo apps (RN mounts after answer), the coordinator's
|
|
283
|
+
// handlePushNotificationAnswer/Reject clears the data before
|
|
284
|
+
// checkForInitialPushNotification ever runs, so no loop occurs.
|
|
279
285
|
return { action: 'incoming_call', metadata: voipPayload.metadata, from_notification: true };
|
|
280
286
|
} catch (parseError) {
|
|
281
287
|
log('Error parsing VoIP push JSON:', parseError);
|
|
@@ -315,11 +321,17 @@ const TelnyxVoiceAppComponent = ({
|
|
|
315
321
|
return;
|
|
316
322
|
}
|
|
317
323
|
log('Processing initial push notification...');
|
|
318
|
-
// Prevent duplicate processing if already connected
|
|
324
|
+
// Prevent duplicate processing if already connected or connecting.
|
|
325
|
+
// Since push data is no longer cleared on read, this guard prevents
|
|
326
|
+
// re-processing when checkForInitialPushNotification fires again on app resume.
|
|
319
327
|
if (
|
|
320
|
-
voipClient.currentConnectionState ===
|
|
328
|
+
voipClient.currentConnectionState ===
|
|
329
|
+
connection_state_1.TelnyxConnectionState.CONNECTED ||
|
|
330
|
+
voipClient.currentConnectionState === connection_state_1.TelnyxConnectionState.CONNECTING
|
|
321
331
|
) {
|
|
322
|
-
log(
|
|
332
|
+
log(
|
|
333
|
+
`SKIPPING - Already ${voipClient.currentConnectionState}, preventing duplicate processing`
|
|
334
|
+
);
|
|
323
335
|
return;
|
|
324
336
|
}
|
|
325
337
|
// Set flags to prevent auto-reconnection during push call
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telnyx/react-voice-commons-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8-beta.1",
|
|
4
4
|
"description": "A high-level, state-agnostic, drop-in module for the Telnyx React Native SDK that simplifies WebRTC voice calling integration",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "lib/index.js",
|
|
@@ -218,24 +218,30 @@ class CallKitCoordinator {
|
|
|
218
218
|
callKitUUID
|
|
219
219
|
);
|
|
220
220
|
|
|
221
|
-
//
|
|
222
|
-
this.
|
|
221
|
+
// Track this call as ended to prevent duplicate end actions
|
|
222
|
+
this.endedCalls.add(callKitUUID);
|
|
223
223
|
|
|
224
224
|
try {
|
|
225
|
-
// End the call in CallKit
|
|
226
|
-
await CallKit.endCall(callKitUUID);
|
|
227
|
-
call.hangup();
|
|
225
|
+
// End the call in CallKit - endCall returns false on failure (doesn't throw)
|
|
226
|
+
const endCallSuccess = await CallKit.endCall(callKitUUID);
|
|
228
227
|
|
|
229
|
-
|
|
228
|
+
if (!endCallSuccess) {
|
|
229
|
+
// Fallback: use reportCallEnded to dismiss CallKit UI when endCall fails
|
|
230
|
+
// (e.g., unknownCallUUID error from duplicate CXProvider)
|
|
231
|
+
await CallKit.reportCallEnded(callKitUUID, CallEndReason.RemoteEnded);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.isCallFromPush = false;
|
|
235
|
+
call.hangup();
|
|
230
236
|
this.cleanupCall(callKitUUID);
|
|
231
237
|
|
|
232
238
|
return true;
|
|
233
239
|
} catch (error) {
|
|
234
240
|
console.error('CallKitCoordinator: Error ending call from UI', error);
|
|
235
|
-
|
|
241
|
+
this.isCallFromPush = false;
|
|
242
|
+
call.hangup();
|
|
243
|
+
this.cleanupCall(callKitUUID);
|
|
236
244
|
return false;
|
|
237
|
-
} finally {
|
|
238
|
-
this.processingCalls.delete(callKitUUID);
|
|
239
245
|
}
|
|
240
246
|
}
|
|
241
247
|
|
|
@@ -306,10 +312,21 @@ class CallKitCoordinator {
|
|
|
306
312
|
} else {
|
|
307
313
|
console.log('CallKitCoordinator: Outgoing call, skipping answer and CONNECTING state');
|
|
308
314
|
}
|
|
315
|
+
// Clear push data now that answer action is fulfilled
|
|
316
|
+
try {
|
|
317
|
+
await VoicePnBridge.clearPendingVoipPush();
|
|
318
|
+
console.log('CallKitCoordinator: Cleared pending VoIP push after answer fulfilled');
|
|
319
|
+
} catch (clearErr) {
|
|
320
|
+
console.error('CallKitCoordinator: Error clearing push data after answer:', clearErr);
|
|
321
|
+
}
|
|
309
322
|
} catch (error) {
|
|
310
323
|
console.error('CallKitCoordinator: Error processing CallKit answer', error);
|
|
311
324
|
await CallKit.reportCallEnded(callKitUUID, CallEndReason.Failed);
|
|
312
325
|
this.cleanupCall(callKitUUID);
|
|
326
|
+
// Clear push data even on error to prevent stale state
|
|
327
|
+
try {
|
|
328
|
+
await VoicePnBridge.clearPendingVoipPush();
|
|
329
|
+
} catch (_) {}
|
|
313
330
|
} finally {
|
|
314
331
|
this.processingCalls.delete(callKitUUID);
|
|
315
332
|
}
|
|
@@ -366,6 +383,12 @@ class CallKitCoordinator {
|
|
|
366
383
|
this.processingCalls.delete(callKitUUID);
|
|
367
384
|
this.cleanupCall(callKitUUID);
|
|
368
385
|
|
|
386
|
+
// Clear push data now that end action is fulfilled
|
|
387
|
+
try {
|
|
388
|
+
await VoicePnBridge.clearPendingVoipPush();
|
|
389
|
+
console.log('CallKitCoordinator: Cleared pending VoIP push after end fulfilled');
|
|
390
|
+
} catch (_) {}
|
|
391
|
+
|
|
369
392
|
// Check if app is in background and no more calls - disconnect client
|
|
370
393
|
await this.checkBackgroundDisconnection();
|
|
371
394
|
}
|
|
@@ -489,19 +512,24 @@ class CallKitCoordinator {
|
|
|
489
512
|
this.shouldAutoAnswerNextCall = true;
|
|
490
513
|
console.log('CallKitCoordinator: ✅ Set auto-answer flag for next incoming call');
|
|
491
514
|
|
|
492
|
-
//
|
|
515
|
+
// Try to get VoIP client - it may not be wired yet if user answered
|
|
516
|
+
// from CallKit before React Native finished initializing
|
|
493
517
|
const voipClient = this.getSDKClient();
|
|
494
518
|
if (!voipClient) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
)
|
|
498
|
-
|
|
499
|
-
|
|
519
|
+
// voipClient not ready yet - DON'T fail the call.
|
|
520
|
+
// shouldAutoAnswerNextCall is already set to true above.
|
|
521
|
+
// checkForInitialPushNotification() will run after setVoipClient()
|
|
522
|
+
// and will find the push data still intact, call handleCallKitPushReceived()
|
|
523
|
+
// which checks shouldAutoAnswerNextCall and queues the auto-answer.
|
|
500
524
|
return;
|
|
501
525
|
}
|
|
502
526
|
|
|
527
|
+
// voipClient is available - queue the answer action on the TelnyxRTC client
|
|
528
|
+
// so when the INVITE arrives after WebSocket login, processInvite() sees
|
|
529
|
+
// pendingAnswerAction=true and auto-answers the call.
|
|
530
|
+
voipClient.queueAnswerFromCallKit();
|
|
531
|
+
|
|
503
532
|
// Get the real push data that was stored by the VoIP push handler
|
|
504
|
-
console.log('CallKitCoordinator: 🔍 Getting real push data from VoicePnBridge...');
|
|
505
533
|
let realPushData = null;
|
|
506
534
|
try {
|
|
507
535
|
const pendingPushJson = await VoicePnBridge.getPendingVoipPush();
|
|
@@ -544,6 +572,12 @@ class CallKitCoordinator {
|
|
|
544
572
|
await VoicePnBridge.setPendingPushAction(pushAction, pushMetadata);
|
|
545
573
|
console.log('CallKitCoordinator: ✅ Set pending push action');
|
|
546
574
|
|
|
575
|
+
// Clear push data now that push notification answer is handled
|
|
576
|
+
try {
|
|
577
|
+
await VoicePnBridge.clearPendingVoipPush();
|
|
578
|
+
console.log('CallKitCoordinator: Cleared pending VoIP push after push answer handled');
|
|
579
|
+
} catch (_) {}
|
|
580
|
+
|
|
547
581
|
return;
|
|
548
582
|
}
|
|
549
583
|
|
|
@@ -577,6 +611,12 @@ class CallKitCoordinator {
|
|
|
577
611
|
// Clean up push notification state
|
|
578
612
|
await this.cleanupPushNotificationState();
|
|
579
613
|
|
|
614
|
+
// Clear push data now that rejection is handled
|
|
615
|
+
try {
|
|
616
|
+
await VoicePnBridge.clearPendingVoipPush();
|
|
617
|
+
console.log('CallKitCoordinator: Cleared pending VoIP push after rejection handled');
|
|
618
|
+
} catch (_) {}
|
|
619
|
+
|
|
580
620
|
console.log('CallKitCoordinator: 🎯 Push notification rejection handling complete');
|
|
581
621
|
return;
|
|
582
622
|
}
|
|
@@ -609,17 +649,11 @@ class CallKitCoordinator {
|
|
|
609
649
|
console.log('CallKitCoordinator: WebRTC call active - reporting connected to CallKit');
|
|
610
650
|
|
|
611
651
|
try {
|
|
612
|
-
// Report as connected (CallKit call already answered in UI flow)
|
|
613
652
|
await CallKit.reportCallConnected(callKitUUID);
|
|
614
|
-
console.log(
|
|
615
|
-
'CallKitCoordinator: Call reported as connected to CallKit ',
|
|
616
|
-
callKitUUID
|
|
617
|
-
);
|
|
618
|
-
|
|
619
|
-
this.connectedCalls.add(callKitUUID);
|
|
620
653
|
} catch (error) {
|
|
621
654
|
console.error('CallKitCoordinator: Error reporting call connected:', error);
|
|
622
655
|
}
|
|
656
|
+
this.connectedCalls.add(callKitUUID);
|
|
623
657
|
}
|
|
624
658
|
break;
|
|
625
659
|
|
|
@@ -815,7 +849,11 @@ class CallKitCoordinator {
|
|
|
815
849
|
* This helps prevent premature flag resets during CallKit operations
|
|
816
850
|
*/
|
|
817
851
|
hasProcessingCalls(): boolean {
|
|
818
|
-
return this
|
|
852
|
+
// Also return true when isCallFromPush is set — this prevents the
|
|
853
|
+
// calls$ subscription in TelnyxVoiceApp from resetting protection flags
|
|
854
|
+
// (isHandlingForegroundCall, backgroundDetectorIgnore) before the WebRTC
|
|
855
|
+
// call arrives during push notification handling.
|
|
856
|
+
return this.processingCalls.size > 0 || this.isCallFromPush;
|
|
819
857
|
}
|
|
820
858
|
|
|
821
859
|
/**
|
package/src/telnyx-voice-app.tsx
CHANGED
|
@@ -374,8 +374,14 @@ const TelnyxVoiceAppComponent: React.FC<TelnyxVoiceAppProps> = ({
|
|
|
374
374
|
|
|
375
375
|
log('Found pending VoIP push data:', voipPayload);
|
|
376
376
|
|
|
377
|
-
|
|
378
|
-
|
|
377
|
+
// Do NOT clear push data here. Let it persist until the answer/end action
|
|
378
|
+
// is fulfilled in the CallKit coordinator. This prevents a race condition in
|
|
379
|
+
// Expo apps where the RN bridge mounts immediately on push notification —
|
|
380
|
+
// the push data would be consumed and cleared before the user answers,
|
|
381
|
+
// leaving the coordinator with nothing to work with.
|
|
382
|
+
// For non-Expo apps (RN mounts after answer), the coordinator's
|
|
383
|
+
// handlePushNotificationAnswer/Reject clears the data before
|
|
384
|
+
// checkForInitialPushNotification ever runs, so no loop occurs.
|
|
379
385
|
|
|
380
386
|
return { action: 'incoming_call', metadata: voipPayload.metadata, from_notification: true };
|
|
381
387
|
} catch (parseError) {
|
|
@@ -424,9 +430,14 @@ const TelnyxVoiceAppComponent: React.FC<TelnyxVoiceAppProps> = ({
|
|
|
424
430
|
|
|
425
431
|
log('Processing initial push notification...');
|
|
426
432
|
|
|
427
|
-
// Prevent duplicate processing if already connected
|
|
428
|
-
|
|
429
|
-
|
|
433
|
+
// Prevent duplicate processing if already connected or connecting.
|
|
434
|
+
// Since push data is no longer cleared on read, this guard prevents
|
|
435
|
+
// re-processing when checkForInitialPushNotification fires again on app resume.
|
|
436
|
+
if (
|
|
437
|
+
voipClient.currentConnectionState === TelnyxConnectionState.CONNECTED ||
|
|
438
|
+
voipClient.currentConnectionState === TelnyxConnectionState.CONNECTING
|
|
439
|
+
) {
|
|
440
|
+
log(`SKIPPING - Already ${voipClient.currentConnectionState}, preventing duplicate processing`);
|
|
430
441
|
return;
|
|
431
442
|
}
|
|
432
443
|
|