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
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
28 changes: 25 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,14 +376,17 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
)
await state.update(from: response)
if ring {
configureOutgoingRingingController()
await MainActor.run { streamVideo.state.ringingCall = self }
}

return response.call
}

/// 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 @@ -1551,6 +1565,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 @@ -1767,4 +1782,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
)
}
}
Loading