Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 30 additions & 27 deletions Sources/StreamVideo/Call.swift
Original file line number Diff line number Diff line change
Expand Up @@ -634,38 +634,41 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {

/// Leave the current call.
///
/// The cleanup sequence clears active/ringing call references from
/// `StreamVideo` state before emitting `CallNotification.callEnded`.
/// The cleanup sequence is coordinated by the ``Call/StateMachine`` so
/// repeated leave requests from UI, CallKit, or backend-event fallbacks
/// share a single teardown path.
///
/// The SDK clears `StreamVideo.State.activeCall` and
/// `StreamVideo.State.ringingCall` before posting
/// `Notification.Name(CallNotification.callEnded)`.
///
/// - Parameter reason: Optional reason forwarded to the SFU leave request.
/// Pass a custom value when you want the backend to distinguish between
/// different leave flows (for example, user action vs timeout).
public func leave(reason: String? = nil) {
disposableBag.removeAll()
callController.leave(reason: reason)
closedCaptionsAdapter.stop()
stateMachine.transition(.idle(.init(call: self)))
/// Upon `Call.leave` we remove the call from the cache. Any further actions that are required
/// to happen on the call object (e.g. rejoin) will need to fetch a new instance from `StreamVideo`
/// client.
callCache.remove(for: cId)
outgoingRingingController = nil

// Reset the activeAudioFilter
setAudioFilter(nil)

let strongSelf = self
let cId = self.cId
Task(disposableBag: disposableBag) { @MainActor [strongSelf, streamVideo, cId] in
if streamVideo.state.ringingCall?.cId == cId {
streamVideo.state.ringingCall = nil
}
if streamVideo.state.activeCall?.cId == cId {
streamVideo.state.activeCall = nil
}

postNotification(with: CallNotification.callEnded, object: strongSelf)
}
stateMachine.transition(
.leaving(
.init(
call: self,
input: .leaving(
.init(
reason: reason,
disposableBag: disposableBag,
callController: callController,
closedCaptionsAdapter: closedCaptionsAdapter,
callCache: callCache,
resetOutgoingRingingController: { [weak self] in
self?.outgoingRingingController = nil
},
resetAudioFilter: { [weak self] in
self?.setAudioFilter(nil)
}
)
)
),
reason: reason
)
)
}

/// Starts noise cancellation asynchronously.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// Copyright © 2026 Stream.io Inc. All rights reserved.
//

import Foundation

extension Call.StateMachine.Stage {

/// Creates a leaving stage for the call state machine.
///
/// - Parameters:
/// - context: The context containing necessary state.
/// - reason: Optional reason forwarded to the backend leave request.
/// - Returns: A new `LeavingStage` instance.
static func leaving(
_ context: Context,
reason: String?
) -> Call.StateMachine.Stage {
LeavingStage(context, reason: reason)
}
}

extension Call.StateMachine.Stage {

/// Represents the leaving stage in the call state machine.
final class LeavingStage: Call.StateMachine.Stage, @unchecked Sendable {
private let reason: String?
private let disposableBag = DisposableBag()

init(
_ context: Context,
reason: String?
) {
self.reason = reason
super.init(id: .leaving, context: context)
}

override func transition(
from previousStage: Call.StateMachine.Stage
) -> Self? {
switch previousStage.id {

Check warning on line 41 in Sources/StreamVideo/CallStateMachine/Stages/Call+LeavingStage.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this "switch" statement with "if" statement to increase readability.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-swift&issues=AZ2jEduAvoSZqufLWGVl&open=AZ2jEduAvoSZqufLWGVl&pullRequest=1124
case .leaving:
return nil
Comment on lines +41 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid logging expected duplicate leaves as invalid transitions.

Returning nil here prevents duplicate cleanup, but StreamStateMachine logs nil transitions as InvalidStateMachineTransition. Since repeated leave requests are expected, guard before calling transition or add a silent idempotent path that preserves the current leaving stage.

One possible fix in Call.leave(reason:)
 public func leave(reason: String? = nil) {
-    stateMachine.transition(
-        .leaving(
+    stateMachine.withLock { currentStage, transitionHandler in
+        guard currentStage.id != .leaving else {
+            return
+        }
+
+        transitionHandler(
+            .leaving(
                 .init(
                     call: self,
                     input: .leaving(
@@
-                reason: reason
+                    reason: reason
+                )
             )
         )
-    )
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/StreamVideo/CallStateMachine/Stages/Call`+LeavingStage.swift around
lines 41 - 43, The code in Call+LeavingStage.swift returns nil for
previousStage.id == .leaving which causes StreamStateMachine to treat repeated
leaves as invalid transitions; change the behavior so duplicate leave requests
are idempotent: either add a guard inside Call.leave(reason:) to no-op if
current stage is .leaving (check the current stage id and return early) or
modify the leaving stage handler to return the existing leaving stage instead of
nil (keep previousStage when previousStage.id == .leaving) so
StreamStateMachine.transition sees a valid no-op transition; reference:
previousStage.id, .leaving, StreamStateMachine.transition, and
Call.leave(reason:).

default:
execute()
return self
}
}

private func execute() {
guard
let call = context.call,
case let .leaving(input) = context.input
else {
return
}

input.disposableBag.removeAll()
input.callController.leave(reason: reason)
input.closedCaptionsAdapter.stop()

/// Upon `Call.leave` we remove the call from the cache. Any
/// further actions that are required to happen on the call object
/// (e.g. rejoin) will need to fetch a new instance from
/// `StreamVideo`.
input.callCache.remove(for: call.cId)
input.resetOutgoingRingingController()
input.resetAudioFilter()

Task(disposableBag: disposableBag) { @MainActor [weak self, call] in
guard let self else {
return
}

if call.streamVideo.state.ringingCall?.cId == call.cId {
call.streamVideo.state.ringingCall = nil
}
if call.streamVideo.state.activeCall?.cId == call.cId {
call.streamVideo.state.activeCall = nil
}

do {
try transition?(.idle(.init(call: call)))
} catch {
log.error(error)
}

postNotification(with: CallNotification.callEnded, object: call)
}
}
}
}
12 changes: 12 additions & 0 deletions Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ extension Call.StateMachine {
enum Input {
case none
case join(JoinInput)
case leaving(LeavingInput)
case accepting(deliverySubject: PassthroughSubject<AcceptCallResponse, Error>)
case rejecting(RejectingInput)
}
Expand Down Expand Up @@ -46,6 +47,16 @@ extension Call.StateMachine {
var deliverySubject: CurrentValueSubject<RejectCallResponse?, Error>
}

struct LeavingInput {
let reason: String?
let disposableBag: DisposableBag
let callController: CallController
let closedCaptionsAdapter: ClosedCaptionsAdapter
let callCache: CallCache
let resetOutgoingRingingController: @Sendable () -> Void
let resetAudioFilter: @Sendable () -> Void
}

weak var call: Call?
var input: Input = .none
var output: Output = .none
Expand All @@ -56,6 +67,7 @@ extension Call.StateMachine {
case idle
case joining
case joined
case leaving
case accepting
case accepted
case rejecting
Expand Down
25 changes: 22 additions & 3 deletions StreamVideoTests/Call/Call_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -734,12 +734,31 @@ final class Call_Tests: StreamVideoTestCase, @unchecked Sendable {

func test_leave_withReason_reasonWasPassedToCallController() {
let mockCallController = MockCallController()
let call = MockCall(.dummy(callController: mockCallController))
call.stub(for: \.state, with: .init(.dummy()))
let subject = MockCall(.dummy(callController: mockCallController))
subject.stub(for: \.state, with: .init(.dummy()))
let expectedReason = "manual-hangup"

subject.leave(reason: expectedReason)

XCTAssertEqual(
mockCallController.recordedInputPayload(
String.self,
for: .leave
)?.first,
expectedReason
)
}

func test_leave_whenCalledRepeatedly_callsCallControllerOnlyOnce() {
let mockCallController = MockCallController()
let subject = MockCall(.dummy(callController: mockCallController))
subject.stub(for: \.state, with: .init(.dummy()))
let expectedReason = "manual-hangup"

call.leave(reason: expectedReason)
subject.leave(reason: expectedReason)
subject.leave(reason: expectedReason)

XCTAssertEqual(mockCallController.timesCalled(.leave), 1)
XCTAssertEqual(
mockCallController.recordedInputPayload(
String.self,
Expand Down
38 changes: 38 additions & 0 deletions StreamVideoTests/StreamVideo_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,44 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable {
XCTAssertNil(streamVideo.state.ringingCall)
}

@MainActor
func test_streamVideo_activeCallReplacement_postsCallEndedNotificationForPreviousCall() async throws {
let subject = StreamVideo.mock(httpClient: HTTPClient_Mock())
self.streamVideo = subject
let previousCallId = String(String.unique.prefix(10))
let previousCallCid = callCid(from: previousCallId, callType: callType)
let nextActiveCall = subject.call(callType: callType, callId: callId)

let notificationExpectation = expectation(
description: "Previous call ended notification"
)
let token = NotificationCenter.default.addObserver(
forName: NSNotification.Name(CallNotification.callEnded),
object: nil,
queue: .main
) { notification in
guard (notification.object as? Call)?.cId == previousCallCid else {
return
}
notificationExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(token) }

do {
let previousCall = subject.call(
callType: callType,
callId: previousCallId
)
subject.state.activeCall = previousCall
subject.state.activeCall = nextActiveCall
}

await fulfillment(of: [notificationExpectation], timeout: defaultTimeout)

XCTAssertTrue(subject.state.activeCall === nextActiveCall)
XCTAssertNil(subject.state.ringingCall)
}

func test_streamVideo_ringCallAccept() async throws {
let httpClient = httpClientWithGetCallResponse()
let streamVideo = StreamVideo.mock(httpClient: httpClient)
Expand Down
Loading