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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### ✅ Added
- Add support for ringing individual members. [#995](https://github.com/GetStream/stream-video-swift/pull/995)

### 🐞 Fixed
- Ensure SFU track and participant updates create missing participants. [#996](https://github.com/GetStream/stream-video-swift/pull/996)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ struct DetailedCallingView<Factory: ViewFactory>: View {
}

enum CallFlow: String, Equatable, CaseIterable {
case joinImmediately = "Join immediately"
case joinImmediately = "Join now"
case ringEvents = "Ring events"
case lobby = "Lobby"
case joinAndRing = "Join and ring"
}

@Injected(\.streamVideo) var streamVideo
Expand Down Expand Up @@ -159,6 +160,13 @@ struct DetailedCallingView<Factory: ViewFactory>: View {
callId: text,
members: members
)
} else if callFlow == .joinAndRing {
viewModel.joinAndRingCall(
callType: callType,
callId: text,
members: members,
video: viewModel.callSettings.videoOn
)
} else {
viewModel.startCall(
callType: callType,
Expand Down
14 changes: 14 additions & 0 deletions Sources/StreamVideo/Call.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,20 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
}
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.
/// - Returns: A `RingCallResponse` with information about the ring operation.
/// - Throws: An error if the coordinator request fails or the call cannot be rung.
@discardableResult
public func ring(request: RingCallRequest) async throws -> RingCallResponse {
let response = try await coordinatorClient.ringCall(
type: callType,
id: callId,
ringCallRequest: request
)
return response
}

/// Updates an existing call with the specified parameters.
/// - Parameters:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ extension Call.StateMachine.Stage {
from previousStage: Call.StateMachine.Stage
) -> Self? {
switch previousStage.id {
case .idle:
case .idle, .joined:
execute()
return self
default:
Expand Down
126 changes: 124 additions & 2 deletions Sources/StreamVideoSwiftUI/CallViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,24 @@ open class CallViewModel: ObservableObject {

/// Tracks the current state of a call. It should be used to show different UI in your views.
@Published public var callingState: CallingState = .idle {
didSet { handleRingingEvents() }
didSet {
// When we join a call and then ring, we need to disable the speaker.
// If the dashboard settings have the speaker on, we need to enable it
// again when we transition into a call.
if let temporaryCallSettings, oldValue == .outgoing && callingState == .inCall {
if temporaryCallSettings.speakerOn {
Task {
do {
try await call?.speaker.enableSpeakerPhone()
} catch {
log.error("Error enabling the speaker: \(error.localizedDescription)")
}
}
}
self.temporaryCallSettings = nil
}
handleRingingEvents()
}
}

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

private var hasAcceptedCall = false
private var skipCallStateUpdates = false
private var temporaryCallSettings: CallSettings?

public var participants: [CallParticipant] {
let updateParticipants = call?.state.participants ?? []
Expand Down Expand Up @@ -431,7 +450,97 @@ open class CallViewModel: ObservableObject {
customData: customData
)
}

/// Joins a call and then rings the specified members.
/// - Parameters:
/// - callType: The type of the call to join (for example, "default").
/// - callId: The unique identifier of the call.
/// - members: The members who should be rung for this call.
/// - team: An optional team identifier to associate with the call.
/// - maxDuration: The maximum duration of the call in seconds.
/// - maxParticipants: The maximum number of participants allowed in the call.
/// - startsAt: An optional scheduled start time for the call.
/// - customData: Optional custom payload to associate with the call on creation.
/// - video: Optional flag indicating whether the ring should suggest a video call.
public func joinAndRingCall(
callType: String,
callId: String,
members: [Member],
team: String? = nil,
maxDuration: Int? = nil,
maxParticipants: Int? = nil,
startsAt: Date? = nil,
customData: [String: RawJSON]? = nil,
video: Bool? = nil
) {
outgoingCallMembers = members
skipCallStateUpdates = true
setCallingState(.outgoing)
let membersRequest: [MemberRequest]? = members.isEmpty
? nil
: members.map(\.toMemberRequest)

if enteringCallTask != nil || callingState == .inCall {
return
}
enteringCallTask = Task(disposableBag: disposableBag, priority: .userInitiated) { [weak self] in
guard let self else { return }
do {
log.debug("Starting call")
let call = call ?? streamVideo.call(
callType: callType,
callId: callId,
callSettings: callSettings
)
var settingsRequest: CallSettingsRequest?
var limits: LimitsSettingsRequest?
if maxDuration != nil || maxParticipants != nil {
limits = .init(maxDurationSeconds: maxDuration, maxParticipants: maxParticipants)
}
settingsRequest = .init(limits: limits)
let options = CreateCallOptions(
members: membersRequest,
custom: customData,
settings: settingsRequest,
startsAt: startsAt,
team: team
)
let settings = localCallSettingsChange ? callSettings : nil

call.updateParticipantsSorting(with: participantsSortComparators)

let joinResponse = try await call.join(
create: true,
options: options,
ring: false,
callSettings: settings
)

temporaryCallSettings = call.state.callSettings
try? await call.speaker.disableSpeakerPhone()

try await call.ring(
request: .init(membersIds: members.map(\.id).filter { $0 != self.streamVideo.user.id }, video: video)
)

let autoCancelTimeoutMs = call.state.settings?.ring.autoCancelTimeoutMs
?? joinResponse.call.settings.ring.autoCancelTimeoutMs
let timeoutSeconds = TimeInterval(autoCancelTimeoutMs) / 1000
startTimer(timeout: timeoutSeconds)
save(call: call)
enteringCallTask = nil
hasAcceptedCall = false
} catch {
hasAcceptedCall = false
log.error("Error starting a call", error: error)
self.error = error
setCallingState(.idle)
audioRecorder.stopRecording()
enteringCallTask = nil
}
}
}

/// Enters into a lobby before joining a call.
/// - Parameters:
/// - callType: the type of the call.
Expand Down Expand Up @@ -593,7 +702,9 @@ open class CallViewModel: ObservableObject {
lineNumber: line
)
if let call, (callingState != .inCall || self.call?.cId != call.cId) {
setCallingState(.inCall)
if !skipCallStateUpdates {
setCallingState(.inCall)
}
self.call = call
} else if call == nil, callingState != .idle {
setCallingState(.idle)
Expand Down Expand Up @@ -656,6 +767,8 @@ open class CallViewModel: ObservableObject {
screenSharingUpdates = nil
recordingUpdates?.cancel()
recordingUpdates = nil
skipCallStateUpdates = false
temporaryCallSettings = nil
call?.leave()

pictureInPictureAdapter.call = nil
Expand Down Expand Up @@ -769,6 +882,9 @@ open class CallViewModel: ObservableObject {
}

private func handleCallHangUp(ringTimeout: Bool = false) {
if skipCallStateUpdates {
skipCallStateUpdates = false
}
guard
let call,
callingState == .outgoing
Expand Down Expand Up @@ -887,6 +1003,7 @@ open class CallViewModel: ObservableObject {
setActiveCall(call)
}
case .outgoing where call?.cId == event.callCid:
skipCallStateUpdates = false
enterCall(
call: call,
callType: event.type,
Expand Down Expand Up @@ -929,6 +1046,10 @@ open class CallViewModel: ObservableObject {
}()
let accepted = outgoingCall.state.session?.acceptedBy.count ?? 0
if accepted == 0, rejections >= outgoingMembersCount {
if skipCallStateUpdates {
skipCallStateUpdates = false
setCallingState(.idle)
}
Task(disposableBag: disposableBag, priority: .userInitiated) { [weak self] in
_ = try? await outgoingCall.reject(
reason: "Call rejected by all \(outgoingMembersCount) outgoing call members."
Expand All @@ -942,6 +1063,7 @@ open class CallViewModel: ObservableObject {
}

private func updateCallStateIfNeeded() {
guard !skipCallStateUpdates else { return }
if callingState == .outgoing {
if !callParticipants.isEmpty {
setCallingState(.inCall)
Expand Down
105 changes: 105 additions & 0 deletions StreamVideoSwiftUITests/CallViewModel_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,110 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable {
await assertCallingState(.inCall)
}

func test_joinAndRingCall_joinsAndRingsMembers() async throws {
// Given
await prepare()
mockCall.resetRecords(for: .join)
mockCall.resetRecords(for: .ring)
let thirdParticipant = try XCTUnwrap(thirdUser)
let recipients: [Member] = participants + [thirdParticipant]
let team = "test-team"
let startsAt = Date(timeIntervalSince1970: 1_700_000_000)
let maxDuration = 600
let maxParticipants = 8
let customData: [String: RawJSON] = ["topic": .string("demo")]
let expectedOptions = CreateCallOptions(
members: recipients.map(\.toMemberRequest),
custom: customData,
settings: CallSettingsRequest(
limits: LimitsSettingsRequest(
maxDurationSeconds: maxDuration,
maxParticipants: maxParticipants
)
),
startsAt: startsAt,
team: team
)

// When
subject.joinAndRingCall(
callType: callType,
callId: callId,
members: recipients,
team: team,
maxDuration: maxDuration,
maxParticipants: maxParticipants,
startsAt: startsAt,
customData: customData,
video: true
)

// Then
XCTAssertEqual(subject.callingState, .outgoing)
XCTAssertEqual(subject.outgoingCallMembers, recipients)

await fulfilmentInMainActor { self.mockCall.timesCalled(.join) == 1 }
let joinPayload = try XCTUnwrap(
mockCall
.recordedInputPayload((Bool, CreateCallOptions?, Bool, Bool, CallSettings?).self, for: .join)?
.last
)
let (createFlag, options, ringFlag, notifyFlag, forwardedCallSettings) = joinPayload
XCTAssertTrue(createFlag)
XCTAssertEqual(options, expectedOptions)
XCTAssertFalse(ringFlag)
XCTAssertFalse(notifyFlag)
XCTAssertNil(forwardedCallSettings)

await fulfilmentInMainActor { self.mockCall.timesCalled(.ring) == 1 }
let ringRequest = try XCTUnwrap(
mockCall
.recordedInputPayload(RingCallRequest.self, for: .ring)?
.last
)
XCTAssertEqual(
ringRequest,
RingCallRequest(
membersIds: [secondUser.id, thirdParticipant.id],
video: true
)
)
}

func test_joinAndRingCall_usesLocalCallSettingsOverrides() async throws {
// Given
await prepare()
mockCall.resetRecords(for: .join)
mockCall.resetRecords(for: .ring)
subject.toggleMicrophoneEnabled()
await fulfilmentInMainActor { self.subject.callSettings.audioOn == false }
let expectedCallSettings = subject.callSettings

// When
subject.joinAndRingCall(
callType: callType,
callId: callId,
members: participants
)

// Then
XCTAssertEqual(subject.callingState, .outgoing)
await fulfilmentInMainActor { self.mockCall.timesCalled(.join) == 1 }
let joinPayload = try XCTUnwrap(
mockCall
.recordedInputPayload((Bool, CreateCallOptions?, Bool, Bool, CallSettings?).self, for: .join)?
.last
)
let (_, _, _, _, forwardedCallSettings) = joinPayload
XCTAssertEqual(forwardedCallSettings, expectedCallSettings)
XCTAssertEqual(
joinPayload.1?.members,
participants.map(\.toMemberRequest)
)

await fulfilmentInMainActor { self.mockCall.timesCalled(.ring) == 1 }
}

// MARK: - EnterLobby

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

streamVideo = .init(stubbedProperty: [:], stubbedFunction: [
.call: mockCall!
Expand Down
4 changes: 4 additions & 0 deletions StreamVideoTests/CallKit/CallKitServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,8 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable {
XCTFail()
case .reject:
XCTFail()
case .ring:
XCTFail()
}
}

Expand Down Expand Up @@ -559,6 +561,8 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable {
XCTFail()
case .reject:
XCTFail()
case .ring:
XCTFail()
}
XCTAssertEqual(call.microphone.status, .enabled)

Expand Down
Loading