diff --git a/CHANGELOG.md b/CHANGELOG.md index cf320b821..b0880ca11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ### 🐞 Fixed - Fix call teardown ordering by posting `callEnded` only after active/ringing cleanup diff --git a/Sources/StreamVideo/Call.swift b/Sources/StreamVideo/Call.swift index 030850d76..80e2eb3d4 100644 --- a/Sources/StreamVideo/Call.swift +++ b/Sources/StreamVideo/Call.swift @@ -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. @@ -263,6 +264,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 } @@ -270,7 +273,12 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { 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 { @@ -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. @@ -365,6 +376,7 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { ) await state.update(from: response) if ring { + configureOutgoingRingingController() await MainActor.run { streamVideo.state.ringingCall = self } } @@ -372,7 +384,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 @@ -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 // Reset the activeAudioFilter setAudioFilter(nil) @@ -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() } + } } diff --git a/Sources/StreamVideo/Controllers/OutgoingRingingController.swift b/Sources/StreamVideo/Controllers/OutgoingRingingController.swift new file mode 100644 index 000000000..de948b085 --- /dev/null +++ b/Sources/StreamVideo/Controllers/OutgoingRingingController.swift @@ -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() + } +} diff --git a/Sources/StreamVideo/StreamVideo.swift b/Sources/StreamVideo/StreamVideo.swift index 9a3062bf6..33c94c0d4 100644 --- a/Sources/StreamVideo/StreamVideo.swift +++ b/Sources/StreamVideo/StreamVideo.swift @@ -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() diff --git a/StreamVideoTests/Controllers/OutgoingRingingController_Tests.swift b/StreamVideoTests/Controllers/OutgoingRingingController_Tests.swift new file mode 100644 index 000000000..5648b985b --- /dev/null +++ b/StreamVideoTests/Controllers/OutgoingRingingController_Tests.swift @@ -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 + ) + } +}