@@ -11,11 +11,17 @@ import StreamWebRTC
1111/// Manages CallKit integration for VoIP calls.
1212open class CallKitService : NSObject , CXProviderDelegate , @unchecked Sendable {
1313
14+ struct MuteRequest : Equatable {
15+ var callUUID : UUID
16+ var isMuted : Bool
17+ }
18+
1419 @Injected ( \. callCache) private var callCache
1520 @Injected ( \. uuidFactory) private var uuidFactory
1621 @Injected ( \. currentDevice) private var currentDevice
1722 @Injected ( \. audioStore) private var audioStore
1823 @Injected ( \. permissions) private var permissions
24+ @Injected ( \. applicationStateAdapter) private var applicationStateAdapter
1925 private let disposableBag = DisposableBag ( )
2026
2127 /// Represents a call that is being managed by the service.
@@ -91,17 +97,17 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
9197
9298 private var _storage : [ UUID : CallEntry ] = [ : ]
9399 private let storageAccessQueue : UnfairQueue = . init( )
94- private var active : UUID ? {
95- didSet { observeCallSettings ( active) }
96- }
100+ private var active : UUID ?
97101
98102 var callCount : Int { storageAccessQueue. sync { _storage. count } }
99103
100104 private var callEndedNotificationCancellable : AnyCancellable ?
101105 private var ringingTimerCancellable : AnyCancellable ?
102106
103- /// Handles audio session changes triggered by CallKit.
104- private lazy var callKitAudioReducer = CallKitAudioSessionReducer ( store: audioStore)
107+ private let muteActionSubject = PassthroughSubject < MuteRequest , Never > ( )
108+ private var muteActionCancellable : AnyCancellable ?
109+ private let muteProcessingQueue = OperationQueue ( maxConcurrentOperationCount: 1 )
110+ private var isMuted : Bool ?
105111
106112 /// Initialize.
107113 override public init ( ) {
@@ -113,6 +119,18 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
113119 . publisher ( for: Notification . Name ( CallNotification . callEnded) )
114120 . compactMap { $0. object as? Call }
115121 . sink { [ weak self] in self ? . callEnded ( $0. cId, ringingTimedOut: false ) }
122+
123+ /// - Important:
124+ /// It used to debounce System's attempts to mute/unmute the call. It seems that the system
125+ /// performs rapid mute/unmute attempts when the call is being joined or moving to foreground.
126+ /// The observation below is in place to guard and normalise those attempts to avoid
127+ /// - rapid speaker and mic toggles
128+ /// - unnecessary attempts to mute/unmute the mic
129+ muteActionCancellable = muteActionSubject
130+ . removeDuplicates ( )
131+ . filter { [ weak self] _ in self ? . applicationStateAdapter. state != . foreground }
132+ . debounce ( for: 0.5 , scheduler: DispatchQueue . global ( qos: . userInteractive) )
133+ . sink { [ weak self] in self ? . performMuteRequest ( $0) }
116134 }
117135
118136 /// Report an incoming call to CallKit.
@@ -394,6 +412,8 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
394412 ///
395413 /// of the audio session during a call.
396414 audioStore. dispatch ( . callKit( . activate( audioSession) ) )
415+
416+ observeCallSettings ( active)
397417 }
398418
399419 public func provider(
@@ -463,27 +483,6 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
463483 log. error ( error, subsystems: . callKit)
464484 action. fail ( )
465485 }
466-
467- let callSettings = callToJoinEntry. call. state. callSettings
468- do {
469- if callSettings. audioOn == false {
470- try await requestTransaction (
471- CXSetMutedCallAction (
472- call: callToJoinEntry. callUUID,
473- muted: true
474- )
475- )
476- }
477- } catch {
478- log. error (
479- """
480- While joining call id: \( callToJoinEntry. call. cId) we failed to mute the microphone.
481- \( callSettings)
482- """ ,
483- subsystems: . callKit,
484- error: error
485- )
486- }
487486 }
488487 }
489488
@@ -555,33 +554,23 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
555554 action. fail ( )
556555 return
557556 }
558- Task ( disposableBag: disposableBag) { [ permissions] in
559- guard permissions. hasMicrophonePermission else {
560- if action. isMuted {
561- action. fulfill ( )
562- } else {
563- action. fail ( )
564- }
565- return
566- }
567557
568- do {
569- if action. isMuted {
570- stackEntry. call. didPerform ( . performSetMutedCall)
571- try await stackEntry. call. microphone. disable ( )
572- } else {
573- stackEntry. call. didPerform ( . performSetMutedCall)
574- try await stackEntry. call. microphone. enable ( )
575- }
576- } catch {
577- log. error (
578- " Unable to perform muteCallAction isMuted: \( action. isMuted) . " ,
579- subsystems: . callKit,
580- error: error
581- )
558+ guard permissions. hasMicrophonePermission else {
559+ if action. isMuted {
560+ action. fulfill ( )
561+ } else {
562+ action. fail ( )
582563 }
583- action . fulfill ( )
564+ return
584565 }
566+
567+ muteActionSubject. send (
568+ . init(
569+ callUUID: stackEntry. callUUID,
570+ isMuted: action. isMuted
571+ )
572+ )
573+ action. fulfill ( )
585574 }
586575
587576 // MARK: - Helpers
@@ -639,12 +628,6 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
639628 /// Called when `StreamVideo` changes. Adds/removes the audio reducer and
640629 /// subscribes to events on real devices.
641630 open func didUpdate( _ streamVideo: StreamVideo ? ) {
642- if streamVideo != nil {
643- audioStore. add ( callKitAudioReducer)
644- } else {
645- audioStore. remove ( callKitAudioReducer)
646- }
647-
648631 guard currentDevice. deviceType != . simulator else {
649632 return
650633 }
@@ -796,19 +779,63 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
796779 . call
797780 . state
798781 . $callSettings
799- . map { ! $0. audioOn }
782+ . map { $0. audioOn == false }
800783 . removeDuplicates ( )
801784 . log ( . debug, subsystems: . callKit) { " Will perform SetMutedCallAction with muted: \( $0) . " }
802- . sinkTask ( storeIn: disposableBag) { [ weak self] in
803- do {
804- try await self ? . requestTransaction ( CXSetMutedCallAction ( call: callUUID, muted: $0) )
805- } catch {
806- log. warning ( " Unable to apply CallSettings.audioOn: \( !$0) . " , subsystems: . callKit)
807- }
808- }
785+ . sink { [ weak self] in self ? . performCallSettingMuteRequest ( $0, callUUID: callUUID) }
809786 . store ( in: disposableBag, key: key)
810787 }
811788 }
789+
790+ private func performCallSettingMuteRequest(
791+ _ muted: Bool ,
792+ callUUID: UUID
793+ ) {
794+ muteProcessingQueue. addTaskOperation { [ weak self] in
795+ guard
796+ let self,
797+ callUUID == active,
798+ isMuted != muted
799+ else {
800+ return
801+ }
802+ do {
803+ try await requestTransaction ( CXSetMutedCallAction ( call: callUUID, muted: muted) )
804+ isMuted = muted
805+ } catch {
806+ log. warning ( " Unable to apply CallSettings.audioOn: \( !muted) . " , subsystems: . callKit)
807+ }
808+ }
809+ }
810+
811+ private func performMuteRequest( _ request: MuteRequest ) {
812+ muteProcessingQueue. addTaskOperation { [ weak self] in
813+ guard
814+ let self,
815+ request. callUUID == active,
816+ isMuted != request. isMuted,
817+ let stackEntry = callEntry ( for: request. callUUID)
818+ else {
819+ return
820+ }
821+
822+ do {
823+ if request. isMuted {
824+ stackEntry. call. didPerform ( . performSetMutedCall)
825+ try await stackEntry. call. microphone. disable ( )
826+ } else {
827+ stackEntry. call. didPerform ( . performSetMutedCall)
828+ try await stackEntry. call. microphone. enable ( )
829+ }
830+ isMuted = request. isMuted
831+ } catch {
832+ log. error (
833+ " Unable to set call uuid: \( request. callUUID) muted: \( request. isMuted) state. " ,
834+ error: error
835+ )
836+ }
837+ }
838+ }
812839}
813840
814841extension CallKitService : InjectionKey {
0 commit comments