Skip to content

Commit d146b3d

Browse files
author
Stream Bot
committed
Implement call leaving stage
1 parent 980c112 commit d146b3d

4 files changed

Lines changed: 267 additions & 31 deletions

File tree

Sources/StreamVideo/Call.swift

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
9494
initialAudioOutputStatus: callSettings?.audioOutputOn == false ? .disabled : .enabled
9595
)
9696

97+
subscribeToCoordinatorCallEnded()
98+
9799
configure(callSettings: callSettings)
98100
}
99101

@@ -624,38 +626,37 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
624626

625627
/// Leave the current call.
626628
///
627-
/// The cleanup sequence clears active/ringing call references from
628-
/// `StreamVideo` state before emitting `CallNotification.callEnded`.
629+
/// The cleanup sequence is coordinated by the ``Call/StateMachine`` so
630+
/// repeated leave requests from UI, CallKit, or backend-event fallbacks
631+
/// share a single teardown path.
629632
///
630633
/// - Parameter reason: Optional reason forwarded to the SFU leave request.
631634
/// Pass a custom value when you want the backend to distinguish between
632635
/// different leave flows (for example, user action vs timeout).
633636
public func leave(reason: String? = nil) {
634-
disposableBag.removeAll()
635-
callController.leave(reason: reason)
636-
closedCaptionsAdapter.stop()
637-
stateMachine.transition(.idle(.init(call: self)))
638-
/// Upon `Call.leave` we remove the call from the cache. Any further actions that are required
639-
/// to happen on the call object (e.g. rejoin) will need to fetch a new instance from `StreamVideo`
640-
/// client.
641-
callCache.remove(for: cId)
642-
outgoingRingingController = nil
643-
644-
// Reset the activeAudioFilter
645-
setAudioFilter(nil)
646-
647-
let strongSelf = self
648-
let cId = self.cId
649-
Task(disposableBag: disposableBag) { @MainActor [strongSelf, streamVideo, cId] in
650-
if streamVideo.state.ringingCall?.cId == cId {
651-
streamVideo.state.ringingCall = nil
652-
}
653-
if streamVideo.state.activeCall?.cId == cId {
654-
streamVideo.state.activeCall = nil
655-
}
656-
657-
postNotification(with: CallNotification.callEnded, object: strongSelf)
658-
}
637+
stateMachine.transition(
638+
.leaving(
639+
.init(
640+
call: self,
641+
input: .leaving(
642+
.init(
643+
reason: reason,
644+
disposableBag: disposableBag,
645+
callController: callController,
646+
closedCaptionsAdapter: closedCaptionsAdapter,
647+
callCache: callCache,
648+
resetOutgoingRingingController: { [weak self] in
649+
self?.outgoingRingingController = nil
650+
},
651+
resetAudioFilter: { [weak self] in
652+
self?.setAudioFilter(nil)
653+
}
654+
)
655+
)
656+
),
657+
reason: reason
658+
)
659+
)
659660
}
660661

661662
/// Starts noise cancellation asynchronously.
@@ -1637,6 +1638,30 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
16371638
return response
16381639
}
16391640

1641+
/// Mirrors backend `call.ended` events into local cleanup for the active
1642+
/// call.
1643+
///
1644+
/// The primary leave path is still driven by WebRTC/SFU transitions.
1645+
/// This subscription acts as a fallback for cases where the backend marks
1646+
/// the call ended and the active `Call` instance has already received the
1647+
/// coordinator event, but the WebRTC leave transition has not yet cleaned
1648+
/// up local state.
1649+
private func subscribeToCoordinatorCallEnded() {
1650+
let cId = self.cId
1651+
eventPublisher(for: CallEndedEvent.self)
1652+
.filter { $0.callCid == cId }
1653+
.sinkTask(storeIn: disposableBag) { @MainActor [weak self] _ in
1654+
guard
1655+
let self,
1656+
streamVideo.state.activeCall === self
1657+
else {
1658+
return
1659+
}
1660+
leave(reason: "call.ended event received")
1661+
}
1662+
.store(in: disposableBag)
1663+
}
1664+
16401665
private func subscribeToNoiseCancellationSettingsChanges() {
16411666
Task(disposableBag: disposableBag) { @MainActor [weak self] in
16421667
guard let self else { return }
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//
2+
// Copyright © 2026 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
7+
extension Call.StateMachine.Stage {
8+
9+
/// Creates a leaving stage for the call state machine.
10+
///
11+
/// - Parameters:
12+
/// - context: The context containing necessary state.
13+
/// - reason: Optional reason forwarded to the backend leave request.
14+
/// - Returns: A new `LeavingStage` instance.
15+
static func leaving(
16+
_ context: Context,
17+
reason: String?
18+
) -> Call.StateMachine.Stage {
19+
LeavingStage(context, reason: reason)
20+
}
21+
}
22+
23+
extension Call.StateMachine.Stage {
24+
25+
/// Represents the leaving stage in the call state machine.
26+
final class LeavingStage: Call.StateMachine.Stage, @unchecked Sendable {
27+
private let reason: String?
28+
private let disposableBag = DisposableBag()
29+
30+
init(
31+
_ context: Context,
32+
reason: String?
33+
) {
34+
self.reason = reason
35+
super.init(id: .leaving, context: context)
36+
}
37+
38+
override func transition(
39+
from previousStage: Call.StateMachine.Stage
40+
) -> Self? {
41+
switch previousStage.id {
42+
case .leaving:
43+
return nil
44+
default:
45+
execute()
46+
return self
47+
}
48+
}
49+
50+
private func execute() {
51+
guard
52+
let call = context.call,
53+
case let .leaving(input) = context.input
54+
else {
55+
return
56+
}
57+
58+
input.disposableBag.removeAll()
59+
input.callController.leave(reason: reason)
60+
input.closedCaptionsAdapter.stop()
61+
62+
/// Upon `Call.leave` we remove the call from the cache. Any
63+
/// further actions that are required to happen on the call object
64+
/// (e.g. rejoin) will need to fetch a new instance from
65+
/// `StreamVideo`.
66+
input.callCache.remove(for: call.cId)
67+
input.resetOutgoingRingingController()
68+
input.resetAudioFilter()
69+
70+
Task(disposableBag: disposableBag) { [weak self, weak call] in
71+
guard let self, let call else {
72+
return
73+
}
74+
75+
await MainActor.run {
76+
if call.streamVideo.state.ringingCall?.cId == call.cId {
77+
call.streamVideo.state.ringingCall = nil
78+
}
79+
if call.streamVideo.state.activeCall?.cId == call.cId {
80+
call.streamVideo.state.activeCall = nil
81+
}
82+
}
83+
84+
do {
85+
try transition?(.idle(.init(call: call)))
86+
} catch {
87+
log.error(error)
88+
}
89+
90+
await MainActor.run {
91+
postNotification(with: CallNotification.callEnded, object: call)
92+
}
93+
}
94+
}
95+
}
96+
}

Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ extension Call.StateMachine {
1313
enum Input {
1414
case none
1515
case join(JoinInput)
16+
case leaving(LeavingInput)
1617
case accepting(deliverySubject: PassthroughSubject<AcceptCallResponse, Error>)
1718
case rejecting(RejectingInput)
1819
}
@@ -46,6 +47,16 @@ extension Call.StateMachine {
4647
var deliverySubject: CurrentValueSubject<RejectCallResponse?, Error>
4748
}
4849

50+
struct LeavingInput {
51+
let reason: String?
52+
let disposableBag: DisposableBag
53+
let callController: CallController
54+
let closedCaptionsAdapter: ClosedCaptionsAdapter
55+
let callCache: CallCache
56+
let resetOutgoingRingingController: @Sendable () -> Void
57+
let resetAudioFilter: @Sendable () -> Void
58+
}
59+
4960
weak var call: Call?
5061
var input: Input = .none
5162
var output: Output = .none
@@ -56,6 +67,7 @@ extension Call.StateMachine {
5667
case idle
5768
case joining
5869
case joined
70+
case leaving
5971
case accepting
6072
case accepted
6173
case rejecting

StreamVideoTests/StreamVideo_Tests.swift

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable {
8282

8383
func test_streamVideo_callEndedNotificationIsPostedAfterCleanup() async throws {
8484
let call = streamVideo.call(callType: callType, callId: callId)
85-
streamVideo.state.activeCall = call
86-
streamVideo.state.ringingCall = call
85+
try await call.join()
86+
await fulfillment { self.streamVideo.state.activeCall === call }
8787

8888
try await withThrowingTaskGroup(of: Void.self) { group in
8989
group.addTask {
@@ -107,6 +107,109 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable {
107107
XCTAssertNil(streamVideo.state.ringingCall)
108108
}
109109

110+
func test_streamVideo_activeCall_whenCallEndedEventReceived_clearsActiveCall() async throws {
111+
let streamVideo = StreamVideo.mock(httpClient: HTTPClient_Mock())
112+
self.streamVideo = streamVideo
113+
let call = streamVideo.call(callType: callType, callId: callId)
114+
115+
try await call.join()
116+
117+
await fulfillment { self.streamVideo.state.activeCall === call }
118+
119+
let callResponse = makeCallResponse()
120+
callResponse.endedAt = Date()
121+
let event = WrappedEvent.coordinatorEvent(
122+
.typeCallEndedEvent(
123+
.init(
124+
call: callResponse,
125+
callCid: cId,
126+
createdAt: Date(),
127+
user: makeUserResponse()
128+
)
129+
)
130+
)
131+
132+
streamVideo.eventNotificationCenter.process(event)
133+
134+
await fulfillment { self.streamVideo.state.activeCall == nil }
135+
136+
XCTAssertNil(streamVideo.state.activeCall)
137+
}
138+
139+
func test_streamVideo_cachedInactiveCall_whenCallEndedEventReceived_doesNotRemoveCachedCall() async throws {
140+
let streamVideo = StreamVideo.mock(httpClient: HTTPClient_Mock())
141+
self.streamVideo = streamVideo
142+
let activeCall = streamVideo.call(callType: callType, callId: callId)
143+
let inactiveCallId = String(String.unique.prefix(10))
144+
let inactiveCall = streamVideo.call(
145+
callType: callType,
146+
callId: inactiveCallId
147+
)
148+
let inactiveCid = callCid(from: inactiveCallId, callType: callType)
149+
150+
try await activeCall.join()
151+
152+
await fulfillment { self.streamVideo.state.activeCall === activeCall }
153+
154+
let callResponse = makeCallResponse(cid: inactiveCid)
155+
callResponse.endedAt = Date()
156+
let event = WrappedEvent.coordinatorEvent(
157+
.typeCallEndedEvent(
158+
.init(
159+
call: callResponse,
160+
callCid: inactiveCid,
161+
createdAt: Date(),
162+
user: makeUserResponse()
163+
)
164+
)
165+
)
166+
167+
streamVideo.eventNotificationCenter.process(event)
168+
169+
await fulfilmentInMainActor {
170+
inactiveCall.state.endedAt != nil
171+
&& streamVideo.state.activeCall === activeCall
172+
}
173+
174+
let cachedInactiveCall = streamVideo.call(
175+
callType: callType,
176+
callId: inactiveCallId
177+
)
178+
179+
XCTAssertTrue(cachedInactiveCall === inactiveCall)
180+
XCTAssertTrue(streamVideo.state.activeCall === activeCall)
181+
}
182+
183+
@MainActor
184+
func test_streamVideo_leaveRepeatedly_postsCallEndedNotificationOnlyOnce() async throws {
185+
let call = streamVideo.call(callType: callType, callId: callId)
186+
try await call.join()
187+
await fulfilmentInMainActor { self.streamVideo.state.activeCall === call }
188+
189+
let notificationExpectation = expectation(description: "Call ended notification")
190+
notificationExpectation.expectedFulfillmentCount = 1
191+
notificationExpectation.assertForOverFulfill = true
192+
let token = NotificationCenter.default.addObserver(
193+
forName: NSNotification.Name(CallNotification.callEnded),
194+
object: nil,
195+
queue: .main
196+
) { notification in
197+
guard (notification.object as? Call)?.cId == call.cId else {
198+
return
199+
}
200+
notificationExpectation.fulfill()
201+
}
202+
defer { NotificationCenter.default.removeObserver(token) }
203+
204+
call.leave()
205+
call.leave()
206+
207+
await fulfillment(of: [notificationExpectation], timeout: defaultTimeout)
208+
await fulfilmentInMainActor { self.streamVideo.state.activeCall == nil }
209+
210+
await wait(for: 0.3)
211+
}
212+
110213
func test_streamVideo_ringCallAccept() async throws {
111214
let httpClient = httpClientWithGetCallResponse()
112215
let streamVideo = StreamVideo.mock(httpClient: httpClient)
@@ -399,8 +502,8 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable {
399502

400503
// MARK: - private
401504

402-
private func makeCallResponse() -> CallResponse {
403-
let callResponse = MockResponseBuilder().makeCallResponse(cid: cId)
505+
private func makeCallResponse(cid: String? = nil) -> CallResponse {
506+
let callResponse = MockResponseBuilder().makeCallResponse(cid: cid ?? cId)
404507
return callResponse
405508
}
406509

0 commit comments

Comments
 (0)