Skip to content

Commit 7d012ce

Browse files
authored
[Fix]CallViewModel ending outgoing group call when one of the participants rejects (#901)
1 parent d4e2551 commit 7d012ce

File tree

6 files changed

+135
-6
lines changed

6 files changed

+135
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44

55
# Upcoming
66

7-
### 🔄 Changed
7+
### 🐞 Fixed
8+
- An issue that caused the CallViewModel to end outgoing group calls prematurely when a participant rejected the call. [#901](https://github.com/GetStream/stream-video-swift/pull/901)
89

910
# [1.29.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.29.0)
1011
_July 21, 2025_

Sources/StreamVideoSwiftUI/CallViewModel.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ open class CallViewModel: ObservableObject {
6464
.sink(receiveValue: { [weak self] settings in
6565
self?.callSettings = settings
6666
})
67+
68+
// We only update the outgoingCallMembers if they are empty (which
69+
// means that the call was created externally)
70+
outgoingCallMembersUpdates = call?
71+
.state
72+
.$members
73+
.filter { [weak self] _ in
74+
self?.outgoingCallMembers.isEmpty == true
75+
&& self?.callingState == .outgoing
76+
}
77+
.receive(on: RunLoop.main)
78+
.assign(to: \.outgoingCallMembers, onWeak: self)
6779
if let callSettings = call?.state.callSettings {
6880
self.callSettings = callSettings
6981
}
@@ -162,6 +174,7 @@ open class CallViewModel: ObservableObject {
162174
private var recordingUpdates: AnyCancellable?
163175
private var screenSharingUpdates: AnyCancellable?
164176
private var callSettingsUpdates: AnyCancellable?
177+
private var outgoingCallMembersUpdates: AnyCancellable?
165178
private var applicationLifecycleUpdates: AnyCancellable?
166179

167180
private var ringingCancellable: AnyCancellable?
@@ -921,7 +934,9 @@ open class CallViewModel: ObservableObject {
921934
let accepted = outgoingCall.state.session?.acceptedBy.count ?? 0
922935
if accepted == 0, rejections >= outgoingMembersCount {
923936
Task(disposableBag: disposableBag, priority: .userInitiated) { [weak self] in
924-
_ = try? await outgoingCall.reject()
937+
_ = try? await outgoingCall.reject(
938+
reason: "Call rejected by all \(outgoingMembersCount) outgoing call members."
939+
)
925940
self?.leaveCall()
926941
}
927942
}

StreamVideo.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@
341341
4072A5922DAEB41500108E8F /* PictureInPictureDelegateProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4072A5912DAEB41500108E8F /* PictureInPictureDelegateProxy.swift */; };
342342
4072A5942DAF992400108E8F /* PictureInPictureParticipantModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4072A5932DAF992400108E8F /* PictureInPictureParticipantModifier.swift */; };
343343
4072A5962DAF99E000108E8F /* PictureInPictureContentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4072A5952DAF99E000108E8F /* PictureInPictureContentProvider.swift */; };
344+
4073BC992E326DAF001E8965 /* MockDefaultAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 404A81302DA3C5F0001F7FA8 /* MockDefaultAPI.swift */; };
344345
407508312DE861E100155CC8 /* DispatchQueueExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407508302DE861E100155CC8 /* DispatchQueueExecutor.swift */; };
345346
4075F0782DCE3FFC0062A4CC /* WebRTCTrackStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4075F0772DCE3FFC0062A4CC /* WebRTCTrackStorage.swift */; };
346347
407AF7142B615D3300E9E3E7 /* CallDurationView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407AF7132B615D3300E9E3E7 /* CallDurationView_Tests.swift */; };
@@ -8641,6 +8642,7 @@
86418642
isa = PBXSourcesBuildPhase;
86428643
buildActionMask = 2147483647;
86438644
files = (
8645+
4073BC992E326DAF001E8965 /* MockDefaultAPI.swift in Sources */,
86448646
40B575D02DCCEBA900F489B8 /* PictureInPictureEnforcedStopAdapterTests.swift in Sources */,
86458647
82FF40BE2A17C73500B4D95E /* CallConnectingView_Tests.swift in Sources */,
86468648
407AF7182B61619900E9E3E7 /* ParticipantListButton_Tests.swift in Sources */,

StreamVideoSwiftUITests/CallViewModel_Tests.swift

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable {
1717
private lazy var callId: String! = UUID().uuidString
1818
private lazy var participants: [Member]! = [firstUser, secondUser]
1919
private var streamVideo: MockStreamVideo!
20-
private lazy var mockCall: MockCall! = .init(.dummy(callType: callType, callId: callId))
20+
private lazy var mockCoordinatorClient: MockDefaultAPI! = .init()
21+
private lazy var mockCall: MockCall! = .init(
22+
.dummy(
23+
callType: callType,
24+
callId: callId,
25+
coordinatorClient: mockCoordinatorClient
26+
)
27+
)
2128

2229
private var subject: CallViewModel!
2330

@@ -31,6 +38,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable {
3138
thirdUser = nil
3239
secondUser = nil
3340
firstUser = nil
41+
mockCoordinatorClient = nil
3442
try await super.tearDown()
3543
}
3644

@@ -210,6 +218,62 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable {
210218
await assertCallingState(.idle)
211219
}
212220

221+
func test_outgoingCall_callCreatedPriorToStarting_rejectedEventFromOneParticipantCallRemainsOngoing() async throws {
222+
LogConfig.level = .debug
223+
// Given
224+
let memberResponses = (participants + [thirdUser]).map {
225+
MemberResponse(
226+
createdAt: .init(),
227+
custom: [:],
228+
updatedAt: .init(),
229+
user: .dummy(id: $0.id),
230+
userId: $0.userId
231+
)
232+
}
233+
mockCoordinatorClient.stub(
234+
for: .getOrCreateCall,
235+
with: GetOrCreateCallResponse(
236+
call: .dummy(
237+
cid: callCid(from: callId, callType: callType),
238+
settings: .dummy(ring: .dummy(autoCancelTimeoutMs: 15000))
239+
),
240+
created: true,
241+
duration: "",
242+
members: memberResponses,
243+
ownCapabilities: []
244+
)
245+
)
246+
await prepare()
247+
mockCall.stub(for: .create, with: ()) // We stub with something irrelevant to cause the mock to forward to super the request
248+
249+
subject.startCall(
250+
callType: .default,
251+
callId: callId,
252+
members: [],
253+
ring: true
254+
)
255+
await assertCallingState(.outgoing)
256+
257+
// When
258+
streamVideo.process(
259+
.coordinatorEvent(
260+
.typeCallRejectedEvent(.dummy(
261+
call: .dummy(
262+
cid: cId,
263+
session: .dummy(
264+
rejectedBy: [secondUser.userId: Date()]
265+
)
266+
),
267+
callCid: cId,
268+
createdAt: Date(),
269+
user: secondUser.user.toUserResponse()
270+
))
271+
)
272+
)
273+
await wait(for: 1.0)
274+
await assertCallingState(.outgoing)
275+
}
276+
213277
func test_outgoingCall_callEndedEvent() async throws {
214278
// Given
215279
await prepare()
@@ -969,8 +1033,6 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable {
9691033
file: StaticString = #file,
9701034
line: UInt = #line
9711035
) async {
972-
LogConfig.level = .debug
973-
9741036
mockCall.stub(
9751037
for: .join,
9761038
with: JoinCallResponse.dummy(

StreamVideoTests/Mock/MockCall.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,26 @@ final class MockCall: Call, Mockable, @unchecked Sendable {
100100
video: Bool? = nil,
101101
transcription: TranscriptionSettingsRequest? = nil
102102
) async throws -> CallResponse {
103-
stubbedFunction[.create] as! CallResponse
103+
if let response = stubbedFunction[.create] as? CallResponse {
104+
return response
105+
} else if let error = stubbedFunction[.create] as? Error {
106+
throw error
107+
} else {
108+
return try await super.create(
109+
members: members,
110+
memberIds: memberIds,
111+
custom: custom,
112+
startsAt: startsAt,
113+
team: team,
114+
ring: ring,
115+
notify: notify,
116+
maxDuration: maxDuration,
117+
maxParticipants: maxParticipants,
118+
backstage: backstage,
119+
video: video,
120+
transcription: transcription
121+
)
122+
}
104123
}
105124

106125
override func get(

StreamVideoTests/Mock/MockDefaultAPI.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ final class MockDefaultAPI: DefaultAPI, Mockable, @unchecked Sendable {
1111
enum MockFunctionKey: Hashable, CaseIterable {
1212
case acceptCall
1313
case rejectCall
14+
case getOrCreateCall
1415
}
1516

1617
enum MockFunctionInputKey: Payloadable {
1718
case acceptCall(type: String, id: String)
1819
case rejectCall(type: String, id: String, request: RejectCallRequest)
20+
case getOrCreateCall(type: String, id: String, getOrCreateCallRequest: GetOrCreateCallRequest)
1921

2022
var payload: Any {
2123
switch self {
@@ -24,6 +26,9 @@ final class MockDefaultAPI: DefaultAPI, Mockable, @unchecked Sendable {
2426

2527
case let .rejectCall(type, id, request):
2628
return (type, id, request)
29+
30+
case let .getOrCreateCall(type, id, request):
31+
return (type, id, request)
2732
}
2833
}
2934
}
@@ -52,6 +57,31 @@ final class MockDefaultAPI: DefaultAPI, Mockable, @unchecked Sendable {
5257

5358
// MARK: - Mocks
5459

60+
override func getOrCreateCall(
61+
type: String,
62+
id: String,
63+
getOrCreateCallRequest: GetOrCreateCallRequest
64+
) async throws -> GetOrCreateCallResponse {
65+
stubbedFunctionInput[.getOrCreateCall]?.append(
66+
.getOrCreateCall(
67+
type: type,
68+
id: id,
69+
getOrCreateCallRequest: getOrCreateCallRequest
70+
)
71+
)
72+
if let response = stubbedFunction[.getOrCreateCall] as? GetOrCreateCallResponse {
73+
return response
74+
} else if let error = stubbedFunction[.getOrCreateCall] as? Error {
75+
throw error
76+
} else {
77+
return try await super.getOrCreateCall(
78+
type: type,
79+
id: id,
80+
getOrCreateCallRequest: getOrCreateCallRequest
81+
)
82+
}
83+
}
84+
5585
override func acceptCall(
5686
type: String,
5787
id: String

0 commit comments

Comments
 (0)