-
Notifications
You must be signed in to change notification settings - Fork 32
[Change]Improve ringing lifecycle #1078
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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 { | ||
|
|
@@ -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,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 | ||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reset Cleanup currently happens only in 🔧 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()
+}.valueAlso applies to: 1788-1793 🤖 Prompt for AI Agents |
||
|
|
||
| // 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() } | ||
| } | ||
| } | ||
| 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 } | ||
ipavlidakis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .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() | ||
| } | ||
| } | ||
| 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 | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix the typo in the changelog entry.
Line 10 says
outgoininstead ofoutgoing.✏️ Proposed fix
📝 Committable suggestion
🧰 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