Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### 🔄 Changed
- Propagated publish/unpublish failures from local video and screen-share capture
sessions instead of swallowing them after logging. [#1072](https://github.com/GetStream/stream-video-swift/pull/1072)
- The SDK will now end an outgoin call if the app moves to background while ringing. [#1078](https://github.com/GetStream/stream-video-swift/pull/1078)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix the typo in the changelog entry.

Line 10 says outgoin instead of outgoing.

✏️ Proposed fix
-- The SDK will now end an outgoin call if the app moves to background while ringing. [`#1078`](https://github.com/GetStream/stream-video-swift/pull/1078)
+- The SDK will now end an outgoing call if the app moves to background while ringing. [`#1078`](https://github.com/GetStream/stream-video-swift/pull/1078)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- The SDK will now end an outgoin call if the app moves to background while ringing. [#1078](https://github.com/GetStream/stream-video-swift/pull/1078)
- The SDK will now end an outgoing call if the app moves to background while ringing. [`#1078`](https://github.com/GetStream/stream-video-swift/pull/1078)
🧰 Tools
🪛 LanguageTool

[grammar] ~10-~10: Ensure spelling is correct
Context: ...ft/pull/1072) - The SDK will now end an outgoin call if the app moves to background whi...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` at line 10, The changelog entry contains a typo: replace the
misspelled word "outgoin" with "outgoing" in the line "- The SDK will now end an
outgoin call if the app moves to background while ringing.
[`#1078`](https://github.com/GetStream/stream-video-swift/pull/1078)" so it reads
"...end an outgoing call..."; update the CHANGELOG.md entry accordingly.


### 🐞 Fixed
- Fix call teardown ordering by posting `callEnded` only after active/ringing cleanup
Expand Down
29 changes: 26 additions & 3 deletions Sources/StreamVideo/Call.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
private let disposableBag = DisposableBag()
internal let callController: CallController
internal let coordinatorClient: DefaultAPIEndpoints
private var outgoingRingingController: OutgoingRingingController?

/// This adapter is used to manage closed captions for the
/// call.
Expand Down Expand Up @@ -263,14 +264,21 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
)
await state.update(from: response)
if ring {
configureOutgoingRingingController()

Task(disposableBag: disposableBag) { @MainActor [weak self] in
self?.streamVideo.state.ringingCall = self
}
}
return response
}

/// Rings the call (sends call notification to members).
/// Rings the call and marks it as `StreamVideo.State.ringingCall`.
///
/// The call stays in the ringing state until it is accepted,
/// rejected, ended, or joined. If the app moves to the background
/// before the ring completes, the SDK ends the outgoing ringing
/// call automatically.
/// - Returns: The call's data.
@discardableResult
public func ring() async throws -> CallResponse {
Expand All @@ -295,7 +303,10 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
/// - custom: An optional dictionary of custom data to include in the call request.
/// - startsAt: An optional `Date` indicating when the call should start.
/// - team: An optional string representing the team for the call.
/// - ring: A boolean indicating whether to ring the call. Default is `false`.
/// - ring: A boolean indicating whether to ring the call. When
/// `true`, the call is exposed through
/// `StreamVideo.State.ringingCall` until it is accepted,
/// rejected, ended, or joined. Default is `false`.
/// - notify: A boolean indicating whether to send notifications. Default is `false`.
/// - maxDuration: An optional integer representing the maximum duration of the call in seconds.
/// - maxParticipants: An optional integer representing the maximum number of participants allowed in the call.
Expand Down Expand Up @@ -365,6 +376,8 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
)
await state.update(from: response)
if ring {
configureOutgoingRingingController()

Task(disposableBag: disposableBag) { @MainActor [weak self] in
self?.streamVideo.state.ringingCall = self
}
Expand All @@ -373,7 +386,9 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
}

/// Initiates a ring action for the current call.
/// - Parameter request: The `RingCallRequest` containing ring configuration, such as member ids and whether it's a video call.
/// - Parameter request: The `RingCallRequest` containing ring
/// configuration, such as member ids and whether it's a video
/// call.
/// - Returns: A `RingCallResponse` with information about the ring operation.
/// - Throws: An error if the coordinator request fails or the call cannot be rung.
@discardableResult
Expand Down Expand Up @@ -1552,6 +1567,7 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
/// 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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reset outgoingRingingController in reject/terminal paths too.

Cleanup currently happens only in performLeave (Line 1570).
In Sources/StreamVideo/CallStateMachine/Stages/Call+RejectingStage.swift (Lines 94-96), rejection clears ringingCall and removes the call from cache without clearing outgoingRingingController, which can leave stale observers alive while the Call is still retained elsewhere.

🔧 Proposed fix direction
// Sources/StreamVideo/Call.swift
+@MainActor
+internal func clearOutgoingRingingController() {
+    outgoingRingingController = nil
+}

 `@MainActor`
 private func performLeave() {
@@
-    outgoingRingingController = nil
+    clearOutgoingRingingController()
@@
 }
// Sources/StreamVideo/CallStateMachine/Stages/Call+RejectingStage.swift
 if streamVideo.state.ringingCall?.cId == call.cId {
     await Task(disposableBag: disposableBag) { `@MainActor` [weak streamVideo] in
         streamVideo?.state.ringingCall = nil
     }.value
 }
+
+await Task(disposableBag: disposableBag) { `@MainActor` [weak call] in
+    call?.clearOutgoingRingingController()
+}.value

Also applies to: 1788-1793

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/StreamVideo/Call.swift` at line 1570, The outgoingRingingController
is only reset in performLeave, leaving observers alive when a call is rejected
or hits terminal paths; update the reject and terminal cleanup paths to also nil
out outgoingRingingController. Specifically, in the rejecting-stage code that
clears ringingCall and removes the call from cache (refer to
Call+RejectingStage.swift where ringingCall is cleared) and in any
terminal/reject handlers, add logic to set Call.outgoingRingingController = nil
(or call the same cleanup helper used by performLeave) so the controller and its
observers are always removed regardless of how the call ends.


// Reset the activeAudioFilter
setAudioFilter(nil)
Expand Down Expand Up @@ -1768,4 +1784,11 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
}
}
}

private func configureOutgoingRingingController() {
outgoingRingingController = .init(
streamVideo: streamVideo,
callCiD: cId
) { [weak self] in try await self?.end() }
}
}
84 changes: 84 additions & 0 deletions Sources/StreamVideo/Controllers/OutgoingRingingController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// Copyright © 2026 Stream.io Inc. All rights reserved.
//

import Combine
import Foundation

/// Observes an outgoing ringing call and ends it when the app moves to
/// the background.
///
/// The controller is active only while
/// `StreamVideo.State.ringingCall` matches the provided call CID.
/// When ringing stops or another call becomes the ringing call, the
/// application state observation is cancelled.
final class OutgoingRingingController: @unchecked Sendable {
@Injected(\.applicationStateAdapter) private var applicationStateAdapter

private let callCiD: String
private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1)
private let handler: () async throws -> Void
private var ringingCallCancellable: AnyCancellable?
private var appStateCancellable: AnyCancellable?
private let disposableBag = DisposableBag()

/// Creates a controller for the outgoing ringing call identified by
/// the provided call CID.
///
/// - Parameters:
/// - streamVideo: The active `StreamVideo` instance.
/// - callCiD: The call CID to observe in `ringingCall`.
/// - handler: The async operation that ends the ringing call.
init(
streamVideo: StreamVideo,
callCiD: String,
handler: @escaping () async throws -> Void
) {
self.callCiD = callCiD
self.handler = handler
ringingCallCancellable = streamVideo
.state
.$ringingCall
.receive(on: processingQueue)
.sink { [weak self] in self?.didUpdateRingingCall($0) }
}

// MARK: - Private Helpers

private func didUpdateRingingCall(_ call: Call?) {
guard call?.cId == callCiD else {
deactivate()
return
}
activate()
}

private func activate() {
appStateCancellable = applicationStateAdapter
.statePublisher
/// We ignore .unknown on purpose to cover cases like, starting a call from the Recents app where
/// entering the ringing flow may happen before the AppState has been stabilised
.filter { $0 == .background }
.log(.warning) { [callCiD] in "Application moved to \($0) while ringing cid:\(callCiD). Ending now." }
.receive(on: processingQueue)
.sinkTask(storeIn: disposableBag) { [weak self] _ in await self?.endCall() }

log.debug("Call cid:\(callCiD) is ringing. Starting application state observation.")
}

private func deactivate() {
appStateCancellable?.cancel()
appStateCancellable = nil

log.debug("Application state observation for cid:\(callCiD) has been deactivated.")
}

private func endCall() async {
do {
try await handler()
} catch {
log.error(error)
}
deactivate()
}
}
5 changes: 5 additions & 0 deletions Sources/StreamVideo/StreamVideo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public class StreamVideo: ObservableObject, @unchecked Sendable {
didSet { didUpdateActiveCall(activeCall, oldValue: oldValue) }
}

/// The call that is currently ringing.
///
/// This is set for both incoming calls and outgoing calls started
/// with `Call.ring()`. The value is cleared after the call is
/// joined, rejected, ended, or promoted to `activeCall`.
@Published public internal(set) var ringingCall: Call?

private nonisolated let disposableBag = DisposableBag()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// Copyright © 2026 Stream.io Inc. All rights reserved.
//

@testable import StreamVideo
@preconcurrency import XCTest

final class OutgoingRingingController_Tests: StreamVideoTestCase, @unchecked Sendable {

private lazy var applicationStateAdapter: MockAppStateAdapter! = .init()
private lazy var callType: String! = .default
private lazy var callId: String! = .unique
private lazy var otherCallId: String! = .unique
private var subject: OutgoingRingingController!

// MARK: - Lifecycle

override func setUp() {
super.setUp()
applicationStateAdapter.makeShared()
}

override func tearDown() {
applicationStateAdapter.dismante()

subject = nil
otherCallId = nil
callId = nil
callType = nil
applicationStateAdapter = nil
super.tearDown()
}

// MARK: - init

func test_matchingRingingCall_onBackground_handlerIsCalled() async {
let call = streamVideo.call(callType: callType, callId: callId)
let handlerWasCalled = expectation(description: "Handler was called.")
subject = makeSubject(for: call) {
handlerWasCalled.fulfill()
}

streamVideo.state.ringingCall = call
applicationStateAdapter.stubbedState = .background

await fulfillment(of: [handlerWasCalled])
}

func test_nonMatchingRingingCall_onBackground_handlerIsNotCalled() async {
let call = streamVideo.call(callType: callType, callId: callId)
let otherCall = streamVideo.call(callType: callType, callId: otherCallId)
let handlerWasCalled = expectation(description: "Handler was called.")
handlerWasCalled.isInverted = true
subject = makeSubject(for: call) {
handlerWasCalled.fulfill()
}

streamVideo.state.ringingCall = otherCall
applicationStateAdapter.stubbedState = .background

await fulfillment(of: [handlerWasCalled], timeout: 0.2)
}

// MARK: - Private Helpers

private func makeSubject(
for call: Call,
handler: @escaping () async throws -> Void
) -> OutgoingRingingController {
.init(
streamVideo: streamVideo,
callCiD: call.cId,
handler: handler
)
}
}