Skip to content

Commit 4e684ef

Browse files
authored
[Fix]CallKit ringing edge cases for already-handled incoming calls (#1115)
1 parent 3a4f64b commit 4e684ef

6 files changed

Lines changed: 746 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1919
- Fix join-call timeout caused by a `PassthroughSubject` race where the response was emitted before the subscription was established. [#1113](https://github.com/GetStream/stream-video-swift/pull/1113)
2020
- CallKit-managed calls now respect the configured `participantAutoLeavePolicy`. [#1112](https://github.com/GetStream/stream-video-swift/pull/1112)
2121
- Prevent `CallViewModel` from entering `.inCall` from participant updates before the call is ready, while preserving the CallKit join handoff to `.inCall`. [#1109](https://github.com/GetStream/stream-video-swift/pull/1109)
22+
- Handle CallKit ringing edge cases by ending already-handled incoming calls with explicit leave reasons when they were answered, rejected, missed, or ended elsewhere. [#1115](https://github.com/GetStream/stream-video-swift/pull/1115)
2223

2324
# [1.45.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.45.0)
2425
_March 31, 2026_

Sources/StreamVideo/CallKit/CallKitService.swift

Lines changed: 98 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,16 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
174174
.default
175175
.publisher(for: Notification.Name(CallNotification.callEnded))
176176
.compactMap { $0.object as? Call }
177-
.sink { [weak self] in self?.callEnded($0.cId, ringingTimedOut: false) }
177+
.sink {
178+
[weak self] in self?.callEnded(
179+
$0.cId,
180+
ringingTimedOut: false,
181+
leaveReason: StreamRejectionReasonProvider
182+
.HandledCallReason
183+
.callEndedLocally
184+
.rawValue
185+
)
186+
}
178187

179188
/// - Important:
180189
/// It used to debounce System's attempts to mute/unmute the call. It seems that the system
@@ -234,7 +243,14 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
234243
""",
235244
subsystems: .callKit
236245
)
237-
callEnded(cid, ringingTimedOut: false)
246+
callEnded(
247+
cid,
248+
ringingTimedOut: false,
249+
leaveReason: StreamRejectionReasonProvider
250+
.HandledCallReason
251+
.notConfigured
252+
.rawValue
253+
)
238254
return
239255
}
240256

@@ -268,21 +284,19 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
268284
.reportCall()
269285

270286
let callState = try await callEntry.call.get()
271-
if !checkIfCallWasHandled(callState: callState) {
272-
callEntry.createdBy = callState.call.createdBy.toUser
273-
setUpRingingTimer(for: callState)
274-
} else {
287+
if let leaveReason = checkIfCallWasHandled(callState: callState) {
275288
log.debug(
276-
"""
277-
Rejecting VoIP incoming call as it has been handled.
278-
callUUID:\(callUUID)
279-
cid:\(cid)
280-
callerId:\(callerId)
281-
callerName:\(localizedCallerName)
282-
""",
289+
"Ending call with reason:\(leaveReason) { uuid:\(callUUID), cid:\(cid), callerId:\(callerId), callerName:\(localizedCallerName) }",
283290
subsystems: .callKit
284291
)
285-
callEnded(cid, ringingTimedOut: false)
292+
callEnded(
293+
cid,
294+
ringingTimedOut: false,
295+
leaveReason: leaveReason
296+
)
297+
} else {
298+
callEntry.createdBy = callState.call.createdBy.toUser
299+
setUpRingingTimer(for: callState)
286300
}
287301
} catch {
288302
log.error(
@@ -295,7 +309,14 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
295309
subsystems: .callKit,
296310
error: error
297311
)
298-
callEnded(cid, ringingTimedOut: false)
312+
callEnded(
313+
cid,
314+
ringingTimedOut: false,
315+
leaveReason: StreamRejectionReasonProvider
316+
.HandledCallReason
317+
.reportCallFailed
318+
.rawValue
319+
)
299320
}
300321
}
301322
}
@@ -376,12 +397,29 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
376397
callCache.remove(for: newCallEntry.call.cId)
377398
}
378399

379-
/// Handle call end event.
400+
/// Handles a ringing or active CallKit call ending.
401+
///
402+
/// Calling this method requests the matching `CXEndCallAction`. If the call
403+
/// is still ringing, the eventual reject request uses `leaveReason` when
404+
/// provided; otherwise the SDK derives a rejection reason from the current
405+
/// ringing state.
406+
///
407+
/// - Parameters:
408+
/// - cId: The call CID managed by CallKit.
409+
/// - ringingTimedOut: Whether the ringing timer elapsed before the user
410+
/// answered.
411+
/// - leaveReason: An explicit backend rejection reason to use for ringing
412+
/// calls that were already handled elsewhere.
380413
open func callEnded(
381414
_ cId: String,
382-
ringingTimedOut: Bool
415+
ringingTimedOut: Bool,
416+
leaveReason: String? = nil
383417
) {
384-
endCall(cId, ringingTimedOut: ringingTimedOut)
418+
endCall(
419+
cId,
420+
ringingTimedOut: ringingTimedOut,
421+
leaveReason: leaveReason
422+
)
385423
}
386424

387425
/// Called when a participant leaves the call.
@@ -569,12 +607,16 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
569607
stackEntry.call.leave(reason: stackEntry.leaveReason)
570608
} else {
571609
do {
572-
let rejectionReason = await streamVideo?
573-
.rejectionReasonProvider
574-
.reason(
575-
for: stackEntry.call.cId,
576-
ringTimeout: stackEntry.ringingTimedOut
577-
)
610+
let rejectionReason = if let leaveReason = stackEntry.leaveReason {
611+
leaveReason
612+
} else {
613+
await streamVideo?
614+
.rejectionReasonProvider
615+
.reason(
616+
for: stackEntry.call.cId,
617+
ringTimeout: stackEntry.ringingTimedOut
618+
)
619+
}
578620
log.debug(
579621
"""
580622
Rejecting with reason: \(rejectionReason ?? "nil")
@@ -639,32 +681,32 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
639681
try await callController.requestTransaction(with: action)
640682
}
641683

642-
/// Return whether this call was already accepted or rejected.
643-
open func checkIfCallWasHandled(callState: GetCallResponse) -> Bool {
684+
/// Checks whether the incoming ringing call was already handled before
685+
/// this device finished presenting CallKit.
686+
///
687+
/// CallKit uses this gate right after fetching the latest backend state,
688+
/// before starting the local ringing timer. If a leave reason is returned,
689+
/// the service immediately ends the CallKit flow instead of continuing to
690+
/// ring locally.
691+
///
692+
/// - Parameter callState: The latest backend state for the ringing call.
693+
/// - Returns: A backend leave reason when CallKit should stop ringing
694+
/// immediately, or `nil` when the local device should keep ringing.
695+
open func checkIfCallWasHandled(callState: GetCallResponse) -> String? {
644696
guard let streamVideo else {
645697
log.warning(
646698
"CallKit operation:\(#function) cannot be fulfilled because StreamVideo is nil.",
647699
subsystems: .callKit
648700
)
649-
return false
650-
}
651-
652-
var allMembers = callState.members.map(\.user.toUser)
653-
let creator = callState.call.createdBy.toUser
654-
let isUserInMembersArray = allMembers.filter { $0.id == creator.id }.isEmpty == false
655-
if !isUserInMembersArray {
656-
allMembers.append(creator)
701+
return StreamRejectionReasonProvider
702+
.HandledCallReason
703+
.notConfigured
704+
.rawValue
657705
}
658-
let allCallees = allMembers.filter { $0.id != creator.id }
659706

660-
let currentUserId = streamVideo.user.id
661-
let acceptedBy = callState.call.session?.acceptedBy ?? [:]
662-
let rejectedBy = callState.call.session?.rejectedBy ?? [:]
663-
let isAccepted = acceptedBy[currentUserId] != nil
664-
let isRejected = rejectedBy[currentUserId] != nil
665-
let isRejectedByEveryoneElse = (allCallees.endIndex > 1)
666-
&& rejectedBy.keys.filter { $0 != currentUserId }.count == (allCallees.endIndex - 1)
667-
return isAccepted || isRejected || isRejectedByEveryoneElse
707+
return streamVideo
708+
.rejectionReasonProvider
709+
.reason(callState: callState)
668710
}
669711

670712
/// Start the ringing timeout timer for the call.
@@ -716,7 +758,14 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
716758
guard let self else { return }
717759
switch event {
718760
case let .typeCallEndedEvent(response):
719-
callEnded(response.callCid, ringingTimedOut: false)
761+
callEnded(
762+
response.callCid,
763+
ringingTimedOut: false,
764+
leaveReason: StreamRejectionReasonProvider
765+
.HandledCallReason
766+
.callEventReceived
767+
.rawValue
768+
)
720769
case let .typeCallAcceptedEvent(response):
721770
callAccepted(response)
722771
case let .typeCallRejectedEvent(response):
@@ -819,10 +868,13 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
819868
return
820869
}
821870

822-
endCall(
871+
callEnded(
823872
activeCallEntry.call.cId,
824873
ringingTimedOut: false,
825-
leaveReason: "auto-leave"
874+
leaveReason: StreamRejectionReasonProvider
875+
.HandledCallReason
876+
.autoLeave
877+
.rawValue
826878
)
827879
}
828880

Sources/StreamVideo/Utils/RejectionReasonProvider/RejectionReasonProvider.swift

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,69 @@ public protocol RejectionReasonProviding: Sendable {
2020
/// - Note: ``ringTimeout`` being true, has an effect **only** when it's set from the side of
2121
/// the caller when the callee doesn't reply the ringing call in the amount of time set on the dashboard.
2222
func reason(for callCid: String, ringTimeout: Bool) async -> String?
23+
24+
/// Determines whether a ringing call was already handled elsewhere and, if
25+
/// so, returns the backend leave reason that should be used locally.
26+
///
27+
/// - Parameter callState: The latest backend state for the ringing call.
28+
/// - Returns: A backend leave reason, or `nil` when the device should keep
29+
/// ringing locally.
30+
func reason(callState: GetCallResponse) -> String?
31+
}
32+
33+
public extension RejectionReasonProviding {
34+
35+
/// Default no-op implementation to preserve source compatibility for
36+
/// existing custom providers that do not yet reason about handled calls.
37+
func reason(callState: GetCallResponse) -> String? {
38+
_ = callState
39+
return nil
40+
}
2341
}
2442

2543
/// A provider that determines the rejection reason for a call based on its state.
2644
final class StreamRejectionReasonProvider: RejectionReasonProviding, @unchecked Sendable {
2745

46+
/// Backend leave reasons for incoming ringing calls that were already
47+
/// handled on another device or by another participant.
48+
enum HandledCallReason: String {
49+
/// Used when CallKit needs a handled-call reason before a
50+
/// `StreamVideo` client is configured on the service/provider.
51+
case notConfigured = "not-configured"
52+
53+
/// Used when reporting the incoming call fails before the local device
54+
/// can continue the ringing flow.
55+
case reportCallFailed = "report-call-failed"
56+
57+
/// Used when a backend `call.ended` event dismisses the local ringing
58+
/// flow before the user answers.
59+
case callEventReceived = "call.ended event received"
60+
61+
/// Used when the latest backend call state already marks the call as
62+
/// ended before CallKit starts ringing locally.
63+
case callHasEnded = "call-has-ended"
64+
65+
/// Used when the local SDK already ended the call and CallKit is being
66+
/// brought back in sync through `CallNotification.callEnded`.
67+
case callEndedLocally = "call-ended-locally"
68+
69+
/// Used when the same user already accepted, rejected, or missed the
70+
/// ringing call on another device.
71+
case userRespondedElsewhere = "user-responded-elsewhere"
72+
73+
/// Used when the caller cancels the ringing flow before any other
74+
/// invitee has accepted the call.
75+
case creatorRejected = "ring: creator rejected"
76+
77+
/// Used when every invitee other than the current user and the caller
78+
/// has already rejected the ringing call.
79+
case allOtherParticipantsRejected = "ring: everyone rejected"
80+
81+
/// Used when the configured participant auto-leave policy ends the
82+
/// active CallKit-managed call.
83+
case autoLeave = "auto-leave"
84+
}
85+
2886
/// The stream video associated with this provider.
2987
private nonisolated(unsafe) weak var streamVideo: StreamVideo?
3088

@@ -66,4 +124,96 @@ final class StreamRejectionReasonProvider: RejectionReasonProviding, @unchecked
66124
: RejectCallRequest.Reason.decline
67125
}
68126
}
127+
128+
/// Returns the backend leave reason for a ringing call that no longer
129+
/// needs to keep ringing on this device.
130+
func reason(callState: GetCallResponse) -> String? {
131+
guard let currentUserId = streamVideo?.user.id else {
132+
return HandledCallReason.notConfigured.rawValue
133+
}
134+
135+
if callHasEnded(callState) {
136+
return HandledCallReason.callHasEnded.rawValue
137+
} else if currentUserRespondedElsewhere(callState, currentUserId: currentUserId) {
138+
return HandledCallReason.userRespondedElsewhere.rawValue
139+
} else if creatorHungUpAndNoOneElseAccepted(callState) {
140+
return HandledCallReason.creatorRejected.rawValue
141+
} else if otherParticipantsRejected(callState, currentUserId: currentUserId) {
142+
return HandledCallReason.allOtherParticipantsRejected.rawValue
143+
} else {
144+
return nil
145+
}
146+
}
147+
148+
// MARK: - Private Helpers
149+
150+
private func callHasEnded(_ callState: GetCallResponse) -> Bool {
151+
callState.call.endedAt != nil
152+
}
153+
154+
private func currentUserRespondedElsewhere(
155+
_ callState: GetCallResponse,
156+
currentUserId: String
157+
) -> Bool {
158+
let hasCurrentUserAcceptedElsewhere =
159+
callState.call.session?.acceptedBy[currentUserId] != nil
160+
let hasCurrentUserRejectedElsewhere =
161+
callState.call.session?.rejectedBy[currentUserId] != nil
162+
let hasCurrentUserMissed =
163+
callState.call.session?.missedBy[currentUserId] != nil
164+
return hasCurrentUserAcceptedElsewhere
165+
|| hasCurrentUserRejectedElsewhere
166+
|| hasCurrentUserMissed
167+
}
168+
169+
private func creatorHungUpAndNoOneElseAccepted(
170+
_ callState: GetCallResponse
171+
) -> Bool {
172+
let creatorId = callState.call.createdBy.id
173+
174+
guard
175+
callState.call.session?.rejectedBy[creatorId] != nil
176+
else {
177+
return false
178+
}
179+
180+
let otherAcceptedParticipants = Set(
181+
(callState.call.session?.acceptedBy ?? [:]).keys.filter { $0 != creatorId }
182+
)
183+
184+
guard otherAcceptedParticipants.isEmpty else {
185+
return false
186+
}
187+
188+
return true
189+
}
190+
191+
private func otherParticipantsRejected(
192+
_ callState: GetCallResponse,
193+
currentUserId: String
194+
) -> Bool {
195+
let creatorId = callState.call.createdBy.id
196+
197+
// Only invitees other than the current user and the creator count
198+
// toward the "everyone else rejected" rule.
199+
let otherParticipants = Set(
200+
callState.members
201+
.map(\.userId)
202+
.filter { $0 != currentUserId && $0 != creatorId }
203+
)
204+
let rejectedOtherParticipants = Set(
205+
(callState.call.session?.rejectedBy ?? [:])
206+
.keys
207+
.filter { $0 != currentUserId && $0 != creatorId }
208+
)
209+
210+
guard
211+
otherParticipants.isEmpty == false,
212+
otherParticipants.isSubset(of: rejectedOtherParticipants)
213+
else {
214+
return false
215+
}
216+
217+
return true
218+
}
69219
}

0 commit comments

Comments
 (0)