Skip to content

Commit fdb8310

Browse files
Implemented ring members in call view model (#995)
1 parent 5a4ca84 commit fdb8310

File tree

9 files changed

+279
-5
lines changed

9 files changed

+279
-5
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44

55
# Upcoming
66

7+
### ✅ Added
8+
- Add support for ringing individual members. [#995](https://github.com/GetStream/stream-video-swift/pull/995)
9+
710
### 🐞 Fixed
811
- Ensure SFU track and participant updates create missing participants. [#996](https://github.com/GetStream/stream-video-swift/pull/996)
912

DemoApp/Sources/Views/CallView/CallingView/DetailedCallingView.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ struct DetailedCallingView<Factory: ViewFactory>: View {
1414
}
1515

1616
enum CallFlow: String, Equatable, CaseIterable {
17-
case joinImmediately = "Join immediately"
17+
case joinImmediately = "Join now"
1818
case ringEvents = "Ring events"
1919
case lobby = "Lobby"
20+
case joinAndRing = "Join and ring"
2021
}
2122

2223
@Injected(\.streamVideo) var streamVideo
@@ -159,6 +160,13 @@ struct DetailedCallingView<Factory: ViewFactory>: View {
159160
callId: text,
160161
members: members
161162
)
163+
} else if callFlow == .joinAndRing {
164+
viewModel.joinAndRingCall(
165+
callType: callType,
166+
callId: text,
167+
members: members,
168+
video: viewModel.callSettings.videoOn
169+
)
162170
} else {
163171
viewModel.startCall(
164172
callType: callType,

Sources/StreamVideo/Call.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,20 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
362362
}
363363
return response.call
364364
}
365+
366+
/// Initiates a ring action for the current call.
367+
/// - Parameter request: The `RingCallRequest` containing ring configuration, such as member ids and whether it's a video call.
368+
/// - Returns: A `RingCallResponse` with information about the ring operation.
369+
/// - Throws: An error if the coordinator request fails or the call cannot be rung.
370+
@discardableResult
371+
public func ring(request: RingCallRequest) async throws -> RingCallResponse {
372+
let response = try await coordinatorClient.ringCall(
373+
type: callType,
374+
id: callId,
375+
ringCallRequest: request
376+
)
377+
return response
378+
}
365379

366380
/// Updates an existing call with the specified parameters.
367381
/// - Parameters:

Sources/StreamVideo/CallStateMachine/Stages/Call+RejectingStage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ extension Call.StateMachine.Stage {
5353
from previousStage: Call.StateMachine.Stage
5454
) -> Self? {
5555
switch previousStage.id {
56-
case .idle:
56+
case .idle, .joined:
5757
execute()
5858
return self
5959
default:

Sources/StreamVideoSwiftUI/CallViewModel.swift

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,24 @@ open class CallViewModel: ObservableObject {
8484

8585
/// Tracks the current state of a call. It should be used to show different UI in your views.
8686
@Published public var callingState: CallingState = .idle {
87-
didSet { handleRingingEvents() }
87+
didSet {
88+
// When we join a call and then ring, we need to disable the speaker.
89+
// If the dashboard settings have the speaker on, we need to enable it
90+
// again when we transition into a call.
91+
if let temporaryCallSettings, oldValue == .outgoing && callingState == .inCall {
92+
if temporaryCallSettings.speakerOn {
93+
Task {
94+
do {
95+
try await call?.speaker.enableSpeakerPhone()
96+
} catch {
97+
log.error("Error enabling the speaker: \(error.localizedDescription)")
98+
}
99+
}
100+
}
101+
self.temporaryCallSettings = nil
102+
}
103+
handleRingingEvents()
104+
}
88105
}
89106

90107
/// Optional, has a value if there was an error. You can use it to display more detailed error messages to the users.
@@ -193,6 +210,8 @@ open class CallViewModel: ObservableObject {
193210
private(set) var localCallSettingsChange = false
194211

195212
private var hasAcceptedCall = false
213+
private var skipCallStateUpdates = false
214+
private var temporaryCallSettings: CallSettings?
196215

197216
public var participants: [CallParticipant] {
198217
let updateParticipants = call?.state.participants ?? []
@@ -431,7 +450,97 @@ open class CallViewModel: ObservableObject {
431450
customData: customData
432451
)
433452
}
453+
454+
/// Joins a call and then rings the specified members.
455+
/// - Parameters:
456+
/// - callType: The type of the call to join (for example, "default").
457+
/// - callId: The unique identifier of the call.
458+
/// - members: The members who should be rung for this call.
459+
/// - team: An optional team identifier to associate with the call.
460+
/// - maxDuration: The maximum duration of the call in seconds.
461+
/// - maxParticipants: The maximum number of participants allowed in the call.
462+
/// - startsAt: An optional scheduled start time for the call.
463+
/// - customData: Optional custom payload to associate with the call on creation.
464+
/// - video: Optional flag indicating whether the ring should suggest a video call.
465+
public func joinAndRingCall(
466+
callType: String,
467+
callId: String,
468+
members: [Member],
469+
team: String? = nil,
470+
maxDuration: Int? = nil,
471+
maxParticipants: Int? = nil,
472+
startsAt: Date? = nil,
473+
customData: [String: RawJSON]? = nil,
474+
video: Bool? = nil
475+
) {
476+
outgoingCallMembers = members
477+
skipCallStateUpdates = true
478+
setCallingState(.outgoing)
479+
let membersRequest: [MemberRequest]? = members.isEmpty
480+
? nil
481+
: members.map(\.toMemberRequest)
482+
483+
if enteringCallTask != nil || callingState == .inCall {
484+
return
485+
}
486+
enteringCallTask = Task(disposableBag: disposableBag, priority: .userInitiated) { [weak self] in
487+
guard let self else { return }
488+
do {
489+
log.debug("Starting call")
490+
let call = call ?? streamVideo.call(
491+
callType: callType,
492+
callId: callId,
493+
callSettings: callSettings
494+
)
495+
var settingsRequest: CallSettingsRequest?
496+
var limits: LimitsSettingsRequest?
497+
if maxDuration != nil || maxParticipants != nil {
498+
limits = .init(maxDurationSeconds: maxDuration, maxParticipants: maxParticipants)
499+
}
500+
settingsRequest = .init(limits: limits)
501+
let options = CreateCallOptions(
502+
members: membersRequest,
503+
custom: customData,
504+
settings: settingsRequest,
505+
startsAt: startsAt,
506+
team: team
507+
)
508+
let settings = localCallSettingsChange ? callSettings : nil
434509

510+
call.updateParticipantsSorting(with: participantsSortComparators)
511+
512+
let joinResponse = try await call.join(
513+
create: true,
514+
options: options,
515+
ring: false,
516+
callSettings: settings
517+
)
518+
519+
temporaryCallSettings = call.state.callSettings
520+
try? await call.speaker.disableSpeakerPhone()
521+
522+
try await call.ring(
523+
request: .init(membersIds: members.map(\.id).filter { $0 != self.streamVideo.user.id }, video: video)
524+
)
525+
526+
let autoCancelTimeoutMs = call.state.settings?.ring.autoCancelTimeoutMs
527+
?? joinResponse.call.settings.ring.autoCancelTimeoutMs
528+
let timeoutSeconds = TimeInterval(autoCancelTimeoutMs) / 1000
529+
startTimer(timeout: timeoutSeconds)
530+
save(call: call)
531+
enteringCallTask = nil
532+
hasAcceptedCall = false
533+
} catch {
534+
hasAcceptedCall = false
535+
log.error("Error starting a call", error: error)
536+
self.error = error
537+
setCallingState(.idle)
538+
audioRecorder.stopRecording()
539+
enteringCallTask = nil
540+
}
541+
}
542+
}
543+
435544
/// Enters into a lobby before joining a call.
436545
/// - Parameters:
437546
/// - callType: the type of the call.
@@ -593,7 +702,9 @@ open class CallViewModel: ObservableObject {
593702
lineNumber: line
594703
)
595704
if let call, (callingState != .inCall || self.call?.cId != call.cId) {
596-
setCallingState(.inCall)
705+
if !skipCallStateUpdates {
706+
setCallingState(.inCall)
707+
}
597708
self.call = call
598709
} else if call == nil, callingState != .idle {
599710
setCallingState(.idle)
@@ -656,6 +767,8 @@ open class CallViewModel: ObservableObject {
656767
screenSharingUpdates = nil
657768
recordingUpdates?.cancel()
658769
recordingUpdates = nil
770+
skipCallStateUpdates = false
771+
temporaryCallSettings = nil
659772
call?.leave()
660773

661774
pictureInPictureAdapter.call = nil
@@ -769,6 +882,9 @@ open class CallViewModel: ObservableObject {
769882
}
770883

771884
private func handleCallHangUp(ringTimeout: Bool = false) {
885+
if skipCallStateUpdates {
886+
skipCallStateUpdates = false
887+
}
772888
guard
773889
let call,
774890
callingState == .outgoing
@@ -887,6 +1003,7 @@ open class CallViewModel: ObservableObject {
8871003
setActiveCall(call)
8881004
}
8891005
case .outgoing where call?.cId == event.callCid:
1006+
skipCallStateUpdates = false
8901007
enterCall(
8911008
call: call,
8921009
callType: event.type,
@@ -929,6 +1046,10 @@ open class CallViewModel: ObservableObject {
9291046
}()
9301047
let accepted = outgoingCall.state.session?.acceptedBy.count ?? 0
9311048
if accepted == 0, rejections >= outgoingMembersCount {
1049+
if skipCallStateUpdates {
1050+
skipCallStateUpdates = false
1051+
setCallingState(.idle)
1052+
}
9321053
Task(disposableBag: disposableBag, priority: .userInitiated) { [weak self] in
9331054
_ = try? await outgoingCall.reject(
9341055
reason: "Call rejected by all \(outgoingMembersCount) outgoing call members."
@@ -942,6 +1063,7 @@ open class CallViewModel: ObservableObject {
9421063
}
9431064

9441065
private func updateCallStateIfNeeded() {
1066+
guard !skipCallStateUpdates else { return }
9451067
if callingState == .outgoing {
9461068
if !callParticipants.isEmpty {
9471069
setCallingState(.inCall)

StreamVideoSwiftUITests/CallViewModel_Tests.swift

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,110 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable {
497497
await assertCallingState(.inCall)
498498
}
499499

500+
func test_joinAndRingCall_joinsAndRingsMembers() async throws {
501+
// Given
502+
await prepare()
503+
mockCall.resetRecords(for: .join)
504+
mockCall.resetRecords(for: .ring)
505+
let thirdParticipant = try XCTUnwrap(thirdUser)
506+
let recipients: [Member] = participants + [thirdParticipant]
507+
let team = "test-team"
508+
let startsAt = Date(timeIntervalSince1970: 1_700_000_000)
509+
let maxDuration = 600
510+
let maxParticipants = 8
511+
let customData: [String: RawJSON] = ["topic": .string("demo")]
512+
let expectedOptions = CreateCallOptions(
513+
members: recipients.map(\.toMemberRequest),
514+
custom: customData,
515+
settings: CallSettingsRequest(
516+
limits: LimitsSettingsRequest(
517+
maxDurationSeconds: maxDuration,
518+
maxParticipants: maxParticipants
519+
)
520+
),
521+
startsAt: startsAt,
522+
team: team
523+
)
524+
525+
// When
526+
subject.joinAndRingCall(
527+
callType: callType,
528+
callId: callId,
529+
members: recipients,
530+
team: team,
531+
maxDuration: maxDuration,
532+
maxParticipants: maxParticipants,
533+
startsAt: startsAt,
534+
customData: customData,
535+
video: true
536+
)
537+
538+
// Then
539+
XCTAssertEqual(subject.callingState, .outgoing)
540+
XCTAssertEqual(subject.outgoingCallMembers, recipients)
541+
542+
await fulfilmentInMainActor { self.mockCall.timesCalled(.join) == 1 }
543+
let joinPayload = try XCTUnwrap(
544+
mockCall
545+
.recordedInputPayload((Bool, CreateCallOptions?, Bool, Bool, CallSettings?).self, for: .join)?
546+
.last
547+
)
548+
let (createFlag, options, ringFlag, notifyFlag, forwardedCallSettings) = joinPayload
549+
XCTAssertTrue(createFlag)
550+
XCTAssertEqual(options, expectedOptions)
551+
XCTAssertFalse(ringFlag)
552+
XCTAssertFalse(notifyFlag)
553+
XCTAssertNil(forwardedCallSettings)
554+
555+
await fulfilmentInMainActor { self.mockCall.timesCalled(.ring) == 1 }
556+
let ringRequest = try XCTUnwrap(
557+
mockCall
558+
.recordedInputPayload(RingCallRequest.self, for: .ring)?
559+
.last
560+
)
561+
XCTAssertEqual(
562+
ringRequest,
563+
RingCallRequest(
564+
membersIds: [secondUser.id, thirdParticipant.id],
565+
video: true
566+
)
567+
)
568+
}
569+
570+
func test_joinAndRingCall_usesLocalCallSettingsOverrides() async throws {
571+
// Given
572+
await prepare()
573+
mockCall.resetRecords(for: .join)
574+
mockCall.resetRecords(for: .ring)
575+
subject.toggleMicrophoneEnabled()
576+
await fulfilmentInMainActor { self.subject.callSettings.audioOn == false }
577+
let expectedCallSettings = subject.callSettings
578+
579+
// When
580+
subject.joinAndRingCall(
581+
callType: callType,
582+
callId: callId,
583+
members: participants
584+
)
585+
586+
// Then
587+
XCTAssertEqual(subject.callingState, .outgoing)
588+
await fulfilmentInMainActor { self.mockCall.timesCalled(.join) == 1 }
589+
let joinPayload = try XCTUnwrap(
590+
mockCall
591+
.recordedInputPayload((Bool, CreateCallOptions?, Bool, Bool, CallSettings?).self, for: .join)?
592+
.last
593+
)
594+
let (_, _, _, _, forwardedCallSettings) = joinPayload
595+
XCTAssertEqual(forwardedCallSettings, expectedCallSettings)
596+
XCTAssertEqual(
597+
joinPayload.1?.members,
598+
participants.map(\.toMemberRequest)
599+
)
600+
601+
await fulfilmentInMainActor { self.mockCall.timesCalled(.ring) == 1 }
602+
}
603+
500604
// MARK: - EnterLobby
501605

502606
func test_enterLobby_joinCall() async throws {
@@ -1059,6 +1163,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable {
10591163
)
10601164
)
10611165
mockCall.stub(for: .reject, with: RejectCallResponse(duration: "0"))
1166+
mockCall.stub(for: .ring, with: RingCallResponse(duration: "0", membersIds: participants.map(\.id)))
10621167

10631168
streamVideo = .init(stubbedProperty: [:], stubbedFunction: [
10641169
.call: mockCall!

StreamVideoTests/CallKit/CallKitServiceTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,8 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable {
461461
XCTFail()
462462
case .reject:
463463
XCTFail()
464+
case .ring:
465+
XCTFail()
464466
}
465467
}
466468

@@ -559,6 +561,8 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable {
559561
XCTFail()
560562
case .reject:
561563
XCTFail()
564+
case .ring:
565+
XCTFail()
562566
}
563567
XCTAssertEqual(call.microphone.status, .enabled)
564568

0 commit comments

Comments
 (0)