@stream-io/react-native-callingx 0.4.0 → 0.5.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.
@@ -2,6 +2,7 @@ import Foundation
2
2
  import CallKit
3
3
  import AVFoundation
4
4
  import UIKit
5
+ import Combine
5
6
  import stream_react_native_webrtc
6
7
 
7
8
  // MARK: - Event Names
@@ -10,6 +11,7 @@ import stream_react_native_webrtc
10
11
  public static let didToggleHoldAction = "didToggleHoldCallAction"
11
12
  public static let didPerformSetMutedCallAction = "didPerformSetMutedCallAction"
12
13
  public static let didChangeAudioRoute = "didChangeAudioRoute"
14
+ public static let didAudioInterruption = "didAudioInterruption"
13
15
  public static let didDisplayIncomingCall = "didDisplayIncomingCall"
14
16
  public static let didActivateAudioSession = "didActivateAudioSession"
15
17
  public static let didDeactivateAudioSession = "didDeactivateAudioSession"
@@ -25,23 +27,30 @@ import stream_react_native_webrtc
25
27
  }
26
28
 
27
29
  // MARK: - Callingx Implementation
28
- @objc public class CallingxImpl: NSObject, CXProviderDelegate {
29
-
30
+ @objc public class CallingxImpl: NSObject, CXProviderDelegate, RTCAudioSessionDelegate {
31
+
30
32
  // MARK: - Shared State
31
33
  @objc public static var sharedProvider: CXProvider?
32
34
  @objc public static var uuidStorage: UUIDStorage?
33
35
  @objc public static var sharedInstance: CallingxImpl?
34
36
  /// Events stored before the module instance exists (e.g. VoIP from killed state). Drained in getInitialEvents().
35
37
  private static var delayedEvents: [[String: Any]] = []
36
-
38
+
37
39
  // MARK: - Instance Properties
38
40
  @objc public var callKeepCallController: CXCallController?
39
41
  @objc public var callKeepProvider: CXProvider?
40
42
  @objc public weak var eventEmitter: CallingxEventEmitter?
41
43
  @objc public weak var webRTCModule: WebRTCModule?
42
-
44
+
43
45
  private var canSendEvents: Bool = false
44
46
  private var isSetup: Bool = false
47
+ /// Combine subscription to the AudioDeviceModule's engine-lifecycle publisher.
48
+ /// Wired lazily in `setup()` because `webRTCModule` (the ADM source) is injected
49
+ /// from the TurboModule host after `init`.
50
+ private var engineSubscription: AnyCancellable?
51
+ /// The ADM `engineSubscription` is bound to. Tracked so we can detect a new ADM
52
+ /// (a JS reload recreates WebRTCModule) and re-wire instead of staying on a dead publisher.
53
+ private weak var subscribedADM: AudioDeviceModule?
45
54
 
46
55
  // Pending CXActions awaiting JS fulfillment
47
56
  private var pendingAnswerActions: [String: (action: CXAnswerCallAction, enqueuedAt: DispatchTime)] = [:]
@@ -50,6 +59,21 @@ import stream_react_native_webrtc
50
59
  // a large timeout to accomodate for cold start + metro server load time
51
60
  private let pendingActionTimeoutSeconds = 30
52
61
 
62
+ /// UUIDs of mute actions the app requested via `setMutedCall`. Lets the perform delegate skip
63
+ /// app-initiated mutes (vs system ones from the native CallKit UI). A set so concurrent toggles
64
+ /// each match their own UUID. Guarded by `pendingActionsQueue`.
65
+ private var appInitiatedMuteActionIds: Set<UUID> = []
66
+
67
+ /// `true` while the audio engine is starting. Startup toggles `voiceProcessingInputMuted`, which
68
+ /// iOS 17+ surfaces as system-initiated mutes — artifacts, not user intent, so we skip them.
69
+ /// Set on `willEnableAudioEngine`, cleared on `willStartAudioEngine`. Guarded by `pendingActionsQueue`.
70
+ private var isAudioEngineStarting = false
71
+
72
+ /// Mute value of the last app-requested `CXSetMutedCallAction`. iOS 17+ round-trips it back as a
73
+ /// system-initiated action of the same value; we skip that echo (a real UI toggle flips the value).
74
+ /// Reset when the call ends. Guarded by `pendingActionsQueue`.
75
+ private var lastAppRequestedMute: Bool?
76
+
53
77
  @objc public static func getSharedInstance() -> CallingxImpl {
54
78
  if sharedInstance == nil {
55
79
  sharedInstance = CallingxImpl()
@@ -64,14 +88,22 @@ import stream_react_native_webrtc
64
88
 
65
89
  isSetup = false
66
90
  canSendEvents = false
67
-
91
+
92
+ // Route changes go through RTCAudioSessionDelegate (fires after WebRTC's
93
+ // internal bookkeeping, so we don't need to defensively re-read currentRoute).
94
+ RTCAudioSession.sharedInstance().add(self)
95
+
96
+ // Interruptions stay on NSNotificationCenter: the delegate's
97
+ // `audioSessionDidBeginInterruption:` callback doesn't carry userInfo, and
98
+ // `AVAudioSessionInterruptionReasonKey` (which we branch on for hardware
99
+ // mic-mute / route-disconnect) lives there.
68
100
  NotificationCenter.default.addObserver(
69
101
  self,
70
- selector: #selector(onAudioRouteChange(_:)),
71
- name: AVAudioSession.routeChangeNotification,
102
+ selector: #selector(onAudioInterruption(_:)),
103
+ name: AVAudioSession.interruptionNotification,
72
104
  object: nil
73
105
  )
74
-
106
+
75
107
  CallingxImpl.sharedInstance = self
76
108
 
77
109
  if CallingxImpl.uuidStorage == nil {
@@ -88,8 +120,11 @@ import stream_react_native_webrtc
88
120
  }
89
121
 
90
122
  deinit {
123
+ RTCAudioSession.sharedInstance().remove(self)
91
124
  NotificationCenter.default.removeObserver(self)
92
-
125
+ engineSubscription?.cancel()
126
+ engineSubscription = nil
127
+
93
128
  callKeepProvider?.setDelegate(nil, queue: nil)
94
129
  callKeepProvider?.invalidate()
95
130
  CallingxImpl.sharedProvider = nil
@@ -122,9 +157,7 @@ import stream_react_native_webrtc
122
157
  guard let storage = uuidStorage else { return }
123
158
 
124
159
  if storage.containsCid(callId) {
125
- #if DEBUG
126
- NSLog("%@","[Callingx][reportNewIncomingCall] callId already exists")
127
- #endif
160
+ CallingxLog.core.debugPublic("[reportNewIncomingCall] callId already exists")
128
161
  completion?()
129
162
  resolve?(true)
130
163
  return
@@ -142,10 +175,7 @@ import stream_react_native_webrtc
142
175
  callUpdate.localizedCallerName = localizedCallerName
143
176
 
144
177
  sharedProvider?.reportNewIncomingCall(with: uuid, update: callUpdate) { error in
145
- #if DEBUG
146
- NSLog("%@","[Callingx][reportNewIncomingCall] callId = \(callId), error = \(String(describing: error))")
147
- #endif
148
-
178
+ CallingxLog.core.debugPublic("[reportNewIncomingCall] callId = \(callId), error = \(String(describing: error))")
149
179
  let errorCode = error != nil ? CallingxImpl.getIncomingCallErrorCode(error!) : ""
150
180
 
151
181
  let body = [
@@ -167,9 +197,7 @@ import stream_react_native_webrtc
167
197
  }
168
198
 
169
199
  if error == nil {
170
- #if DEBUG
171
- NSLog("%@","[Callingx][reportNewIncomingCall] success callId = \(callId)")
172
- #endif
200
+ CallingxLog.core.debugPublic("[reportNewIncomingCall] success callId = \(callId)")
173
201
  resolve?(true)
174
202
  } else {
175
203
  reject?("DISPLAY_INCOMING_CALL_ERROR", error?.localizedDescription, error)
@@ -211,14 +239,10 @@ import stream_react_native_webrtc
211
239
  }
212
240
 
213
241
  @objc public static func endCall(_ callId: String, reason: Int) {
214
- #if DEBUG
215
- NSLog("%@","[Callingx][endCall] callId = \(callId) reason = \(reason)")
216
- #endif
242
+ CallingxLog.core.debugPublic("[endCall] callId = \(callId) reason = \(reason)")
217
243
 
218
244
  guard let call = uuidStorage?.getCall(forCid: callId) else {
219
- #if DEBUG
220
- NSLog("%@","[Callingx][endCall] callId not found")
221
- #endif
245
+ CallingxLog.core.debugPublic("[endCall] callId not found")
222
246
  return
223
247
  }
224
248
 
@@ -249,9 +273,7 @@ import stream_react_native_webrtc
249
273
 
250
274
  // MARK: - Instance Methods
251
275
  @objc public func requestTransaction(_ transaction: CXTransaction) {
252
- #if DEBUG
253
- NSLog("%@","[Callingx][requestTransaction] transaction = \(transaction)")
254
- #endif
276
+ CallingxLog.core.debugPublic("[requestTransaction] transaction = \(transaction)")
255
277
 
256
278
  if callKeepCallController == nil {
257
279
  callKeepCallController = CXCallController()
@@ -259,21 +281,22 @@ import stream_react_native_webrtc
259
281
 
260
282
  callKeepCallController?.request(transaction) { [weak self] error in
261
283
  if let error = error {
262
- #if DEBUG
263
- NSLog("%@","[Callingx][requestTransaction] Error requesting transaction (\(transaction.actions)): (\(error))")
264
- #endif
284
+ CallingxLog.core.errorPublic("[requestTransaction] Error requesting transaction (\(transaction.actions)): (\(error))")
265
285
 
266
286
  // Reset per-call action-source flags for all actions in the failed transaction
267
287
  for action in transaction.actions {
288
+ if let mutedAction = action as? CXSetMutedCallAction {
289
+ self?.pendingActionsQueue.sync {
290
+ _ = self?.appInitiatedMuteActionIds.remove(mutedAction.uuid)
291
+ }
292
+ }
268
293
  if let callAction = action as? CXCallAction,
269
294
  let call = CallingxImpl.uuidStorage?.getCallByUUID(callAction.callUUID) {
270
295
  call.resetAllSelfFlags()
271
296
  }
272
297
  }
273
298
  } else {
274
- #if DEBUG
275
- NSLog("%@","[Callingx][requestTransaction] Requested transaction successfully")
276
- #endif
299
+ CallingxLog.core.debugPublic("[requestTransaction] Requested transaction successfully")
277
300
 
278
301
  if let startCallAction = transaction.actions.first as? CXStartCallAction {
279
302
  let callUpdate = CXCallUpdate()
@@ -292,9 +315,7 @@ import stream_react_native_webrtc
292
315
  }
293
316
 
294
317
  @objc public func sendEvent(_ name: String, body: [String: Any]?) {
295
- #if DEBUG
296
- NSLog("%@","[Callingx] sendEventWithNameWrapper: \(name)")
297
- #endif
318
+ CallingxLog.core.debugPublic("sendEventWithNameWrapper: \(name)")
298
319
 
299
320
  let sendEventAction = {
300
321
  var dictionary: [String: Any] = ["eventName": name]
@@ -306,9 +327,7 @@ import stream_react_native_webrtc
306
327
  self.eventEmitter?.emitEvent(dictionary)
307
328
  } else {
308
329
  CallingxImpl.delayedEvents.append(dictionary)
309
- #if DEBUG
310
- NSLog("%@","[Callingx] delayedEvents: \(CallingxImpl.delayedEvents)")
311
- #endif
330
+ CallingxLog.core.debugPublic("delayedEvents: \(CallingxImpl.delayedEvents)")
312
331
  }
313
332
  }
314
333
 
@@ -321,27 +340,91 @@ import stream_react_native_webrtc
321
340
  }
322
341
  }
323
342
 
324
- @objc private func onAudioRouteChange(_ notification: Notification) {
325
- guard let info = notification.userInfo,
326
- let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
327
- let output = CallingxImpl.getAudioOutput() else {
343
+ // MARK: - RTCAudioSessionDelegate
344
+
345
+ public func audioSessionDidChangeRoute(_ session: RTCAudioSession,
346
+ reason: AVAudioSession.RouteChangeReason,
347
+ previousRoute: AVAudioSessionRouteDescription) {
348
+ guard let output = CallingxImpl.getAudioOutput() else {
328
349
  return
329
350
  }
330
-
351
+
331
352
  let params: [String: Any] = [
332
353
  "output": output,
333
- "reason": reasonValue
354
+ "reason": reason.rawValue
334
355
  ]
335
-
356
+
336
357
  sendEvent(CallingxEvents.didChangeAudioRoute, body: params)
337
358
  }
338
-
359
+
360
+ // MARK: - Audio Session Interruption
361
+
362
+ // Observability + JS-event only; audio recovery is WebRTC's: AudioEngineDevice
363
+ // restarts the engine on interruption-end. We do not touch the session here.
364
+ @objc private func onAudioInterruption(_ notification: Notification) {
365
+ guard CallingxSessionOwnership.callingxOwnsSession else {
366
+ return
367
+ }
368
+
369
+ guard let info = notification.userInfo,
370
+ let typeRaw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
371
+ let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else {
372
+ return
373
+ }
374
+
375
+ let reason = interruptionReason(info)
376
+ var payload: [String: Any] = ["source": "callingx"]
377
+ if let reason {
378
+ payload["reason"] = reason
379
+ }
380
+
381
+ switch type {
382
+ case .began:
383
+ payload["phase"] = "began"
384
+ sendEvent(CallingxEvents.didAudioInterruption, body: payload)
385
+ CallingxLog.core.debugPublic("Audio interruption began (reason=\(reason ?? "n/a")). Recovery owned by WebRTC AudioEngineDevice.")
386
+ case .ended:
387
+ var shouldResume = false
388
+ if let optsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt {
389
+ shouldResume = AVAudioSession.InterruptionOptions(rawValue: optsRaw).contains(.shouldResume)
390
+ }
391
+ payload["phase"] = "ended"
392
+ payload["shouldResume"] = shouldResume
393
+ sendEvent(CallingxEvents.didAudioInterruption, body: payload)
394
+ CallingxLog.core.debugPublic("Audio interruption ended (shouldResume=\(shouldResume)). WebRTC restarts the engine.")
395
+ @unknown default:
396
+ break
397
+ }
398
+ }
399
+
400
+ private func interruptionReason(_ info: [AnyHashable: Any]) -> String? {
401
+ guard #available(iOS 14.5, *),
402
+ let reasonRaw = info[AVAudioSessionInterruptionReasonKey] as? UInt,
403
+ let reason = AVAudioSession.InterruptionReason(rawValue: reasonRaw) else {
404
+ return nil
405
+ }
406
+ if #available(iOS 17.0, *) {
407
+ switch reason {
408
+ case .builtInMicMuted:
409
+ return "builtInMicMuted"
410
+ case .routeDisconnected:
411
+ return "routeDisconnected"
412
+ default:
413
+ break
414
+ }
415
+ }
416
+ if reason == .default {
417
+ return "default"
418
+ }
419
+ return "raw(\(reason.rawValue))"
420
+ }
421
+
339
422
  // MARK: - Setup Methods
340
423
  @objc public func setup(options: [String: Any]) {
341
424
  callKeepCallController = CXCallController()
342
-
425
+
343
426
  Settings.setSettings(options)
344
-
427
+
345
428
  // This is mostly needed for very first setup, as we need to override the default
346
429
  // provider configuration which is set in the constructor.
347
430
  // IMPORTANT: We override CXProvider instance only if there is no registered call, otherwise we may lose corrsponding call state/events from CallKit
@@ -349,23 +432,48 @@ import stream_react_native_webrtc
349
432
  let oldProvider = CallingxImpl.sharedProvider
350
433
  let newProvider = CXProvider(configuration: Settings.getProviderConfiguration())
351
434
  newProvider.setDelegate(self, queue: nil)
352
-
435
+
353
436
  CallingxImpl.sharedProvider = newProvider
354
437
  callKeepProvider = newProvider
355
-
438
+
356
439
  oldProvider?.setDelegate(nil, queue: nil)
357
440
  oldProvider?.invalidate()
358
441
  }
359
442
 
360
443
  isSetup = true
361
444
  }
445
+
446
+ /// Wires the ADM engine-lifecycle subscription. Call after `webRTCModule` is injected
447
+ /// (it's nil during `setup()` on the callingx path). Re-wires when the ADM changes — a JS
448
+ /// reload recreates WebRTCModule while this singleton persists; a no-op for the same ADM.
449
+ @objc public func wireEngineSubscription() {
450
+ guard let adm = getAudioDeviceModule() else { return }
451
+ guard subscribedADM !== adm else { return } // already wired to this ADM
452
+ engineSubscription?.cancel() // ADM changed (e.g. JS reload) — rewire
453
+ subscribedADM = adm
454
+ CallingxLog.core.debugPublic("[wireEngineSubscription]")
455
+
456
+ engineSubscription = adm.publisher.sink { [weak self] event in
457
+ guard CallingxSessionOwnership.callingxOwnsSession else { return }
458
+ switch event {
459
+ case .willEnableAudioEngine:
460
+ self?.pendingActionsQueue.sync { self?.isAudioEngineStarting = true }
461
+ AudioSessionManager.shared.engineWillEnable()
462
+ case .willStartAudioEngine:
463
+ // Engine is now rendering; voice-processing mute reflects real intent again.
464
+ self?.pendingActionsQueue.sync { self?.isAudioEngineStarting = false }
465
+ case .didDisableAudioEngine:
466
+ AudioSessionManager.shared.engineDidDisable()
467
+ default:
468
+ break
469
+ }
470
+ }
471
+ }
362
472
 
363
473
  @objc public func getInitialEvents() -> [[String: Any]] {
364
474
  var events: [[String: Any]] = []
365
475
  let action = {
366
- #if DEBUG
367
- NSLog("%@","[Callingx][getInitialEvents] delayedEvents = \(CallingxImpl.delayedEvents)")
368
- #endif
476
+ CallingxLog.core.debugPublic("[getInitialEvents] delayedEvents = \(CallingxImpl.delayedEvents)")
369
477
 
370
478
  events = CallingxImpl.delayedEvents
371
479
  CallingxImpl.delayedEvents = []
@@ -385,22 +493,16 @@ import stream_react_native_webrtc
385
493
 
386
494
  // MARK: - Call Management
387
495
  @objc public func answerIncomingCall(_ callId: String) -> Bool {
388
- #if DEBUG
389
- NSLog("%@","[Callingx][answerIncomingCall] callId = \(callId)")
390
- #endif
496
+ CallingxLog.core.debugPublic("[answerIncomingCall] callId = \(callId)")
391
497
 
392
498
  guard let call = CallingxImpl.uuidStorage?.getCall(forCid: callId) else {
393
- #if DEBUG
394
- NSLog("%@","[Callingx][answerIncomingCall] callId not found")
395
- #endif
499
+ CallingxLog.core.debugPublic("[answerIncomingCall] callId not found")
396
500
  return false
397
501
  }
398
502
 
399
503
  // Guard: already answered or ended — prevent duplicate CXAnswerCallAction transactions
400
504
  if call.isAnswered || call.hasEnded {
401
- #if DEBUG
402
- NSLog("%@","[Callingx][answerIncomingCall] callId already answered/ended, skipping")
403
- #endif
505
+ CallingxLog.core.debugPublic("[answerIncomingCall] callId already answered/ended, skipping")
404
506
  return true
405
507
  }
406
508
 
@@ -447,9 +549,7 @@ import stream_react_native_webrtc
447
549
  let popTime = DispatchTime.now() + .milliseconds(timeout)
448
550
  DispatchQueue.main.asyncAfter(deadline: popTime) { [weak self] in
449
551
  guard let self = self, !self.isSetup else { return }
450
- #if DEBUG
451
- NSLog("%@","[Callingx] Displayed a call without a reachable app, ending the call: \(callId)")
452
- #endif
552
+ CallingxLog.core.debugPublic("Displayed a call without a reachable app, ending the call: \(callId)")
453
553
  CallingxImpl.endCall(callId, reason: CXCallEndedReason.failed.rawValue)
454
554
  }
455
555
  }
@@ -457,22 +557,16 @@ import stream_react_native_webrtc
457
557
  }
458
558
 
459
559
  @objc public func endCall(_ callId: String) -> Bool {
460
- #if DEBUG
461
- NSLog("%@","[Callingx][endCall] callId = \(callId)")
462
- #endif
560
+ CallingxLog.core.debugPublic("[endCall] callId = \(callId)")
463
561
 
464
562
  guard let call = CallingxImpl.uuidStorage?.getCall(forCid: callId) else {
465
- #if DEBUG
466
- NSLog("%@","[Callingx][endCall] callId not found")
467
- #endif
563
+ CallingxLog.core.debugPublic("[endCall] callId not found")
468
564
  return false
469
565
  }
470
566
 
471
567
  // Guard: already ended — prevent duplicate CXEndCallAction transactions
472
568
  if call.hasEnded {
473
- #if DEBUG
474
- NSLog("%@","[Callingx][endCall] callId already ended, skipping")
475
- #endif
569
+ CallingxLog.core.debugPublic("[endCall] callId already ended, skipping")
476
570
  return true
477
571
  }
478
572
 
@@ -488,9 +582,7 @@ import stream_react_native_webrtc
488
582
 
489
583
  @objc public func isCallTracked(_ callId: String) -> Bool {
490
584
  guard let uuid = CallingxImpl.uuidStorage?.getUUID(forCid: callId) else {
491
- #if DEBUG
492
- NSLog("%@","[Callingx][isCallTracked] callId not found")
493
- #endif
585
+ CallingxLog.core.debugPublic("[isCallTracked] callId not found")
494
586
  return false
495
587
  }
496
588
 
@@ -504,14 +596,10 @@ import stream_react_native_webrtc
504
596
  }
505
597
 
506
598
  @objc public func setCurrentCallActive(_ callId: String) -> Bool {
507
- #if DEBUG
508
- NSLog("%@","[Callingx][setCurrentCallActive] callId = \(callId)")
509
- #endif
599
+ CallingxLog.core.debugPublic("[setCurrentCallActive] callId = \(callId)")
510
600
 
511
601
  guard let call = CallingxImpl.uuidStorage?.getCall(forCid: callId) else {
512
- #if DEBUG
513
- NSLog("%@","[Callingx][setCurrentCallActive] callId not found")
514
- #endif
602
+ CallingxLog.core.debugPublic("[setCurrentCallActive] callId not found")
515
603
  return false
516
604
  }
517
605
 
@@ -524,35 +612,30 @@ import stream_react_native_webrtc
524
612
  }
525
613
 
526
614
  @objc public func setMutedCall(_ callId: String, isMuted: Bool) -> Bool {
527
- #if DEBUG
528
- NSLog("%@","[Callingx][setMutedCall] muted = \(isMuted)")
529
- #endif
530
-
615
+ CallingxLog.core.debugPublic("[setMutedCall] muted = \(isMuted)")
531
616
  guard let call = CallingxImpl.uuidStorage?.getCall(forCid: callId) else {
532
- #if DEBUG
533
- NSLog("%@","[Callingx][setMutedCall] callId not found")
534
- #endif
617
+ CallingxLog.core.debugPublic("[setMutedCall] callId not found")
535
618
  return false
536
619
  }
537
620
 
538
- call.markSelfMuted()
539
621
  let setMutedAction = CXSetMutedCallAction(call: call.uuid, muted: isMuted)
622
+ // Record the action UUID so the perform delegate can recognize this as app-initiated
623
+ // (and skip echoing it back to JS) without racing on a shared per-call flag.
624
+ pendingActionsQueue.sync {
625
+ _ = appInitiatedMuteActionIds.insert(setMutedAction.uuid)
626
+ }
540
627
  let transaction = CXTransaction()
541
628
  transaction.addAction(setMutedAction)
542
-
629
+
543
630
  requestTransaction(transaction)
544
631
  return true
545
632
  }
546
633
 
547
634
  @objc public func setOnHoldCall(_ callId: String, isOnHold: Bool) -> Bool {
548
- #if DEBUG
549
- NSLog("%@","[Callingx][setOnHold] uuidString = \(callId), shouldHold = \(isOnHold)")
550
- #endif
635
+ CallingxLog.core.debugPublic("[setOnHold] uuidString = \(callId), shouldHold = \(isOnHold)")
551
636
 
552
637
  guard let uuid = CallingxImpl.uuidStorage?.getUUID(forCid: callId) else {
553
- #if DEBUG
554
- NSLog("%@","[Callingx][setOnHoldCall] callId not found")
555
- #endif
638
+ CallingxLog.core.debugPublic("[setOnHoldCall] callId not found")
556
639
  return false
557
640
  }
558
641
 
@@ -570,16 +653,12 @@ import stream_react_native_webrtc
570
653
  callerName: String,
571
654
  hasVideo: Bool
572
655
  ) {
573
- #if DEBUG
574
- NSLog("%@","[Callingx][startCall] uuidString = \(callId), phoneNumber = \(phoneNumber)")
575
- #endif
656
+ CallingxLog.core.debugPublic("[startCall] uuidString = \(callId), phoneNumber = \(phoneNumber)")
576
657
 
577
658
  guard let storage = CallingxImpl.uuidStorage else { return }
578
659
 
579
660
  if (storage.containsCid(callId)) {
580
- #if DEBUG
581
- NSLog("%@","[Callingx][startCall] Call \(callId) is already registered")
582
- #endif
661
+ CallingxLog.core.debugPublic("[startCall] Call \(callId) is already registered")
583
662
  return
584
663
  }
585
664
 
@@ -601,14 +680,10 @@ import stream_react_native_webrtc
601
680
  phoneNumber: String,
602
681
  callerName: String
603
682
  ) -> Bool {
604
- #if DEBUG
605
- NSLog("%@","[Callingx][updateDisplay] uuidString = \(callId) displayName = \(callerName) uri = \(phoneNumber)")
606
- #endif
683
+ CallingxLog.core.debugPublic("[updateDisplay] uuidString = \(callId) displayName = \(callerName) uri = \(phoneNumber)")
607
684
 
608
685
  guard let uuid = CallingxImpl.uuidStorage?.getUUID(forCid: callId) else {
609
- #if DEBUG
610
- NSLog("%@","[Callingx][updateDisplay] callId not found")
611
- #endif
686
+ CallingxLog.core.debugPublic("[updateDisplay] callId not found")
612
687
  return false
613
688
  }
614
689
 
@@ -625,20 +700,19 @@ import stream_react_native_webrtc
625
700
 
626
701
  // MARK: - CXProviderDelegate
627
702
  public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
628
- #if DEBUG
629
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performStartCallAction]")
630
- #endif
631
-
703
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performStartCallAction]")
704
+
632
705
  guard let call = CallingxImpl.uuidStorage?.getCallByUUID(action.callUUID) else {
633
- #if DEBUG
634
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performStartCallAction] callId not found")
635
- #endif
706
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performStartCallAction] callId not found")
636
707
  action.fail()
637
708
  return
638
709
  }
639
-
640
- getAudioDeviceModule()?.reset()
641
- AudioSessionManager.createAudioSessionIfNeeded()
710
+
711
+ // Claim audio-session ownership BEFORE createAudioSessionIfNeeded:
712
+ // both can synchronously fire .didDisableAudioEngine / .willEnableAudioEngine
713
+ // through the ADM publisher. The engine sink gates on this flag.
714
+ CallingxSessionOwnership.callingxOwnsSession = true
715
+ AudioSessionManager.shared.createAudioSessionIfNeeded()
642
716
 
643
717
  sendEvent(CallingxEvents.didReceiveStartCallAction, body: [
644
718
  "callId": call.cid,
@@ -654,19 +728,17 @@ import stream_react_native_webrtc
654
728
 
655
729
  public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
656
730
  guard let call = CallingxImpl.uuidStorage?.getCallByUUID(action.callUUID) else {
657
- #if DEBUG
658
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performAnswerCallAction] callId not found")
659
- #endif
731
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performAnswerCallAction] callId not found")
660
732
  action.fail()
661
733
  return
662
734
  }
663
735
 
664
- #if DEBUG
665
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performAnswerCallAction] isSelfAnswered: \(call.isSelfAnswered)")
666
- #endif
667
-
668
- getAudioDeviceModule()?.reset()
669
- AudioSessionManager.createAudioSessionIfNeeded()
736
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performAnswerCallAction] isSelfAnswered: \(call.isSelfAnswered)")
737
+ // Claim audio-session ownership BEFORE adm.reset() and createAudioSessionIfNeeded:
738
+ // both can synchronously fire .didDisableAudioEngine / .willEnableAudioEngine
739
+ // through the ADM publisher. The engine sink gates on this flag.
740
+ CallingxSessionOwnership.callingxOwnsSession = true
741
+ AudioSessionManager.shared.createAudioSessionIfNeeded()
670
742
 
671
743
  let source = call.isSelfAnswered ? "app" : "sys"
672
744
  sendEvent(CallingxEvents.performAnswerCallAction, body: [
@@ -691,9 +763,7 @@ import stream_react_native_webrtc
691
763
  let timeout = DispatchTime.now() + DispatchTimeInterval.seconds(pendingActionTimeoutSeconds)
692
764
  pendingActionsQueue.asyncAfter(deadline: timeout) { [weak self] in
693
765
  if let pending = self?.pendingAnswerActions.removeValue(forKey: cid) {
694
- #if DEBUG
695
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performAnswerCallAction] answer timeout for callId: \(cid)")
696
- #endif
766
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performAnswerCallAction] answer timeout for callId: \(cid)")
697
767
  pending.action.fail()
698
768
  }
699
769
  }
@@ -702,18 +772,14 @@ import stream_react_native_webrtc
702
772
 
703
773
  public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
704
774
  guard let call = CallingxImpl.uuidStorage?.getCallByUUID(action.callUUID) else {
705
- #if DEBUG
706
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performEndCallAction] callId not found")
707
- #endif
775
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performEndCallAction] callId not found")
708
776
  // End actions represent explicit user intent to close call UI.
709
777
  // Fulfill stale/duplicate end actions to avoid "Call Failed" UX.
710
778
  action.fulfill()
711
779
  return
712
780
  }
713
781
 
714
- #if DEBUG
715
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performEndCallAction] isSelfEnded: \(call.isSelfEnded)")
716
- #endif
782
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performEndCallAction] isSelfEnded: \(call.isSelfEnded)")
717
783
 
718
784
  let source = call.isSelfEnded ? "app" : "sys"
719
785
  sendEvent(CallingxEvents.performEndCallAction, body: [
@@ -724,6 +790,8 @@ import stream_react_native_webrtc
724
790
  call.resetSelfEnded()
725
791
  call.markEnded()
726
792
  CallingxImpl.uuidStorage?.removeCid(call.cid)
793
+ // Forget this call's mute intent so its stale value can't be read as an echo next call.
794
+ pendingActionsQueue.sync { lastAppRequestedMute = nil }
727
795
 
728
796
  if source == "app" {
729
797
  // App initiated this end — no need to wait for JS, fulfill immediately
@@ -738,9 +806,7 @@ import stream_react_native_webrtc
738
806
  let timeout = DispatchTime.now() + DispatchTimeInterval.seconds(pendingActionTimeoutSeconds)
739
807
  pendingActionsQueue.asyncAfter(deadline: timeout) { [weak self] in
740
808
  if let pending = self?.pendingEndActions.removeValue(forKey: cid) {
741
- #if DEBUG
742
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performEndCallAction] end timeout for callId: \(cid)")
743
- #endif
809
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performEndCallAction] end timeout for callId: \(cid)")
744
810
  pending.action.fulfill()
745
811
  }
746
812
  }
@@ -748,14 +814,10 @@ import stream_react_native_webrtc
748
814
  }
749
815
 
750
816
  public func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
751
- #if DEBUG
752
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performSetHeldCallAction]")
753
- #endif
817
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performSetHeldCallAction]")
754
818
 
755
819
  guard let callId = CallingxImpl.uuidStorage?.getCid(forUUID: action.callUUID) else {
756
- #if DEBUG
757
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performSetHeldCallAction] callId not found")
758
- #endif
820
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performSetHeldCallAction] callId not found")
759
821
  action.fail()
760
822
  return
761
823
  }
@@ -770,25 +832,25 @@ import stream_react_native_webrtc
770
832
 
771
833
  public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
772
834
  guard let call = CallingxImpl.uuidStorage?.getCallByUUID(action.callUUID) else {
773
- #if DEBUG
774
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performSetMutedCallAction] callId not found")
775
- #endif
835
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performSetMutedCallAction] callId not found")
776
836
  action.fail()
777
837
  return
778
838
  }
779
839
 
780
- let isAppInitiated = call.isSelfMuted
781
- call.resetSelfMuted()
782
-
783
- #if DEBUG
784
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performSetMutedCallAction] \(action.isMuted) isAppInitiated: \(isAppInitiated)")
785
- #endif
786
-
787
- // Only send the event to JS when the mute was initiated by the system
788
- // (e.g. user tapped mute on the native CallKit UI).
789
- // Skip app-initiated actions to prevent the feedback loop:
790
- // app mutes mic setMutedCall CallKit delegate event to JS mic toggle → loop
791
- if !isAppInitiated {
840
+ // Resolve all three suppression flags in one queue hop (serialized state).
841
+ let (isAppInitiated, suppressDuringStartup, isMuteEcho) = pendingActionsQueue.sync { () -> (Bool, Bool, Bool) in
842
+ let appInitiated = appInitiatedMuteActionIds.remove(action.uuid) != nil
843
+ // Remember the value so its iOS 17+ system echo can be skipped below.
844
+ if appInitiated { lastAppRequestedMute = action.isMuted }
845
+ let echo = !appInitiated && lastAppRequestedMute == action.isMuted
846
+ return (appInitiated, isAudioEngineStarting, echo)
847
+ }
848
+
849
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performSetMutedCallAction] \(action.isMuted) isAppInitiated: \(isAppInitiated) suppressDuringStartup: \(suppressDuringStartup) isMuteEcho: \(isMuteEcho)")
850
+ // Forward to JS only genuine system mutes (user tapped native CallKit UI). Skip app-initiated
851
+ // actions (feedback loop), their iOS 17+ system echoes, and engine-startup artifacts —
852
+ // see each flag's field docs.
853
+ if !isAppInitiated && !suppressDuringStartup && !isMuteEcho {
792
854
  sendEvent(CallingxEvents.didPerformSetMutedCallAction, body: [
793
855
  "muted": action.isMuted,
794
856
  "callId": call.cid
@@ -799,14 +861,10 @@ import stream_react_native_webrtc
799
861
  }
800
862
 
801
863
  public func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) {
802
- #if DEBUG
803
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performPlayDTMFCallAction]")
804
- #endif
864
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performPlayDTMFCallAction]")
805
865
 
806
866
  guard let callId = CallingxImpl.uuidStorage?.getCid(forUUID: action.callUUID) else {
807
- #if DEBUG
808
- NSLog("%@","[Callingx][CXProviderDelegate][provider:performPlayDTMFCallAction] callId not found")
809
- #endif
867
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:performPlayDTMFCallAction] callId not found")
810
868
  action.fail()
811
869
  return
812
870
  }
@@ -820,17 +878,17 @@ import stream_react_native_webrtc
820
878
  }
821
879
 
822
880
  public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
823
- #if DEBUG
824
- NSLog("%@","[Callingx][CXProviderDelegate][provider:didActivateAudioSession] category=\(audioSession.category) mode=\(audioSession.mode)")
825
- #endif
881
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:didActivateAudioSession] category=\(audioSession.category) mode=\(audioSession.mode)")
882
+ // Re-claim ownership BEFORE notifying WebRTC. Handles the PSTN/Siri
883
+ // interruption-resume case: didDeactivate cleared the flag if the call
884
+ // had ended, but for an interruption the call is still tracked and
885
+ // ownership was preserved — re-asserting here is a no-op then, and
886
+ // closes any edge case where it had been cleared.
887
+ CallingxSessionOwnership.callingxOwnsSession = true
826
888
 
827
889
  // When CallKit activates the AVAudioSession, inform WebRTC as well.
828
890
  RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
829
891
 
830
- // No-op on the first didActivate per call (CXAction.perform already configured);
831
- // only fires for interruption recovery / unhold cycles. See Apple Forums 749202.
832
- AudioSessionManager.reapplyForDidActivateIfNeeded()
833
-
834
892
  // Enable wake lock to keep the device awake during the call
835
893
  DispatchQueue.main.async {
836
894
  UIApplication.shared.isIdleTimerDisabled = true
@@ -840,14 +898,22 @@ import stream_react_native_webrtc
840
898
  }
841
899
 
842
900
  public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
843
- #if DEBUG
844
- NSLog("%@","[Callingx][CXProviderDelegate][provider:didDeactivateAudioSession] category=\(audioSession.category) mode=\(audioSession.mode)")
845
- #endif
901
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:didDeactivateAudioSession] category=\(audioSession.category) mode=\(audioSession.mode)")
846
902
 
847
903
  // When CallKit deactivates the AVAudioSession, inform WebRTC as well.
848
904
  RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
849
905
  getAudioDeviceModule()?.reset()
850
- AudioSessionManager.resetActivationCycle()
906
+
907
+ // Invariant: callingx ships with maximumCallsPerCallGroup = maximumCallGroups = 1
908
+ // (see packages/react-native-callingx/src/utils/constants.ts defaultiOSOptions).
909
+ // So `UUIDStorage.count() == 0` reliably distinguishes:
910
+ // - true end-of-call (call removed in CXEndCallAction.perform before didDeactivate)
911
+ // - PSTN/Siri interruption (call still tracked, will resume via didActivate)
912
+ // Do NOT "fix" this to handle multi-call semantics — the product does not support
913
+ // concurrent CallKit calls. See plan: critically-review-the-implementation-zesty-spindle.
914
+ if let storage = CallingxImpl.uuidStorage, storage.count() == 0 {
915
+ CallingxSessionOwnership.callingxOwnsSession = false
916
+ }
851
917
 
852
918
  // Disable wake lock when the call ends
853
919
  DispatchQueue.main.async {
@@ -860,9 +926,7 @@ import stream_react_native_webrtc
860
926
  public func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
861
927
  // note: in practice we should never be getting this callback as we already have a pending timeout set.
862
928
  // in our tests callkit timesout and exectutes this method in approximately 60 seconds.
863
- #if DEBUG
864
- NSLog("%@","[Callingx][CXProviderDelegate][provider:timedOutPerformingAction]")
865
- #endif
929
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:timedOutPerformingAction]")
866
930
 
867
931
  guard let callAction = action as? CXCallAction else {
868
932
  return
@@ -873,33 +937,36 @@ import stream_react_native_webrtc
873
937
  if let answerEntry = pendingAnswerActions.first(where: { $0.value.action.callUUID == callAction.callUUID }) {
874
938
  pendingAnswerActions.removeValue(forKey: answerEntry.key)
875
939
  let elapsedMs = elapsedMilliseconds(since: answerEntry.value.enqueuedAt)
876
- #if DEBUG
877
- NSLog("%@","[Callingx][CXProviderDelegate][provider:timedOutPerformingAction] removed pending answer action for callId: \(answerEntry.key), elapsedMs=\(elapsedMs)")
878
- #endif
940
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:timedOutPerformingAction] removed pending answer action for callId: \(answerEntry.key), elapsedMs=\(elapsedMs)")
879
941
  }
880
942
 
881
943
  if let endEntry = pendingEndActions.first(where: { $0.value.action.callUUID == callAction.callUUID }) {
882
944
  pendingEndActions.removeValue(forKey: endEntry.key)
883
945
  let elapsedMs = elapsedMilliseconds(since: endEntry.value.enqueuedAt)
884
- #if DEBUG
885
- NSLog("%@","[Callingx][CXProviderDelegate][provider:timedOutPerformingAction] removed pending end action for callId: \(endEntry.key), elapsedMs=\(elapsedMs)")
886
- #endif
946
+ CallingxLog.core.debugPublic("[CXProviderDelegate][provider:timedOutPerformingAction] removed pending end action for callId: \(endEntry.key), elapsedMs=\(elapsedMs)")
887
947
  }
888
948
  }
889
949
  }
890
950
 
891
951
  public func providerDidReset(_ provider: CXProvider) {
892
- #if DEBUG
893
- NSLog("%@","[Callingx][providerDidReset]")
894
- #endif
952
+ CallingxLog.core.debugPublic("[providerDidReset]")
895
953
 
896
954
  // Clear any pending actions to prevent memory leaks.
897
955
  // After a provider reset, all pending CXActions are invalid.
898
956
  pendingActionsQueue.sync {
899
957
  pendingAnswerActions.removeAll()
900
958
  pendingEndActions.removeAll()
959
+ lastAppRequestedMute = nil
901
960
  }
902
961
 
962
+ // A provider reset invalidates all CallKit calls. didDeactivate is not
963
+ // guaranteed to fire in its usual shape afterwards, so release ownership
964
+ // here and wipe UUIDStorage to keep the `count() == 0` discriminator in
965
+ // didDeactivate honest (stale entries would otherwise refuse to release
966
+ // ownership on the next end-of-call).
967
+ CallingxImpl.uuidStorage?.removeAllObjects()
968
+ CallingxSessionOwnership.callingxOwnsSession = false
969
+
903
970
  sendEvent(CallingxEvents.providerReset, body: nil)
904
971
  }
905
972
 
@@ -908,15 +975,11 @@ import stream_react_native_webrtc
908
975
  @objc public func fulfillAnswerCallAction(_ callId: String, didFail: Bool) {
909
976
  pendingActionsQueue.sync { [weak self] in
910
977
  guard let pending = self?.pendingAnswerActions.removeValue(forKey: callId) else {
911
- #if DEBUG
912
- NSLog("%@","[Callingx][fulfillAnswerCallAction] action not found for callId: \(callId)")
913
- #endif
978
+ CallingxLog.core.debugPublic("[fulfillAnswerCallAction] action not found for callId: \(callId)")
914
979
  return
915
980
  }
916
981
  let elapsedMs = elapsedMilliseconds(since: pending.enqueuedAt)
917
- #if DEBUG
918
- NSLog("%@","[Callingx][fulfillAnswerCallAction] callId: \(callId), didFail: \(didFail), elapsedMs=\(elapsedMs)")
919
- #endif
982
+ CallingxLog.core.debugPublic("[fulfillAnswerCallAction] callId: \(callId), didFail: \(didFail), elapsedMs=\(elapsedMs)")
920
983
  if didFail { pending.action.fail() } else { pending.action.fulfill() }
921
984
  }
922
985
  }
@@ -924,15 +987,11 @@ import stream_react_native_webrtc
924
987
  @objc public func fulfillEndCallAction(_ callId: String, didFail: Bool) {
925
988
  pendingActionsQueue.sync { [weak self] in
926
989
  guard let pending = self?.pendingEndActions.removeValue(forKey: callId) else {
927
- #if DEBUG
928
- NSLog("%@","[Callingx][fulfillEndCallAction] action not found for callId: \(callId)")
929
- #endif
990
+ CallingxLog.core.debugPublic("[fulfillEndCallAction] action not found for callId: \(callId)")
930
991
  return
931
992
  }
932
993
  let elapsedMs = elapsedMilliseconds(since: pending.enqueuedAt)
933
- #if DEBUG
934
- NSLog("%@","[Callingx][fulfillEndCallAction] callId: \(callId), didFail: \(didFail), elapsedMs=\(elapsedMs)")
935
- #endif
994
+ CallingxLog.core.debugPublic("[fulfillEndCallAction] callId: \(callId), didFail: \(didFail), elapsedMs=\(elapsedMs)")
936
995
  if didFail { pending.action.fail() } else { pending.action.fulfill() }
937
996
  }
938
997
  }
@@ -940,7 +999,7 @@ import stream_react_native_webrtc
940
999
  // MARK: - Audio Configuration
941
1000
 
942
1001
  @objc public func setDefaultAudioDeviceEndpointType(_ endpointType: String) {
943
- AudioSessionManager.setDefaultAudioDeviceEndpointType(endpointType)
1002
+ AudioSessionManager.shared.setDefaultAudioDeviceEndpointType(endpointType)
944
1003
  }
945
1004
 
946
1005
  // MARK: - Helper Methods
@@ -953,12 +1012,9 @@ import stream_react_native_webrtc
953
1012
 
954
1013
  private func getAudioDeviceModule() -> AudioDeviceModule? {
955
1014
  guard let adm = webRTCModule?.audioDeviceModule else {
956
- #if DEBUG
957
- NSLog("%@","[Callingx] WebRTCModule is not available. Ensure it was injected from the TurboModule host.")
958
- #endif
1015
+ CallingxLog.core.errorPublic("WebRTCModule is not available. Ensure it was injected from the TurboModule host.")
959
1016
  return nil
960
1017
  }
961
1018
  return adm
962
1019
  }
963
1020
  }
964
-