diff --git a/Sources/StreamVideo/CallKit/CallKitService.swift b/Sources/StreamVideo/CallKit/CallKitService.swift index 124cbc5b6..1667205e9 100644 --- a/Sources/StreamVideo/CallKit/CallKitService.swift +++ b/Sources/StreamVideo/CallKit/CallKitService.swift @@ -10,6 +10,9 @@ import Foundation /// facilitating VoIP calls in an application. open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { + @Injected(\.callCache) private var callCache + @Injected(\.uuidFactory) private var uuidFactory + /// Represents a call that is being managed by the service. final class CallEntry: Equatable, @unchecked Sendable { var call: Call @@ -157,20 +160,21 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { /// /// - Parameter response: The call accepted event. open func callAccepted(_ response: CallAcceptedEvent) { + /// The call was accepted somewhere else (e.g the incoming call on the same device or another + /// device). No action is required. guard let newCallEntry = storage.first(where: { $0.value.call.cId == response.callCid })?.value, newCallEntry.callUUID != active // Ensure that the new call isn't the currently active one. else { return } - Task { - do { - // Update call state to inCall and send the answer call action. - try await requestTransaction(CXAnswerCallAction(call: newCallEntry.callUUID)) - } catch { - log.error(error) - } - } + callProvider.reportCall( + with: newCallEntry.callUUID, + endedAt: nil, + reason: .answeredElsewhere + ) + storage[newCallEntry.callUUID] = nil + callCache.remove(for: newCallEntry.call.cId) } /// Handles the event when a call is rejected. @@ -192,14 +196,13 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { else { return } - Task { - do { - // End the call if rejected. - try await requestTransaction(CXEndCallAction(call: newCallEntry.callUUID)) - } catch { - log.error(error) - } - } + callProvider.reportCall( + with: newCallEntry.callUUID, + endedAt: nil, + reason: .declinedElsewhere + ) + storage[newCallEntry.callUUID] = nil + callCache.remove(for: newCallEntry.call.cId) } /// Handles the event when a call ends. @@ -294,6 +297,7 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { ) { ringingTimerCancellable?.cancel() ringingTimerCancellable = nil + let currentCallWasEnded = action.callUUID == active guard let stackEntry = storage[action.callUUID] else { action.fail() @@ -310,7 +314,9 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { } catch { log.error(error) } - stackEntry.call.leave() + if currentCallWasEnded { + stackEntry.call.leave() + } storage[action.callUUID] = nil action.fulfill() } @@ -372,6 +378,10 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { // MARK: - Private helpers + /// Subscription to event should **never** perform an accept or joining a call action. Those actions + /// are only being performed explicitly from the component that receives the user action. + /// Subscribing to events is being used to reject/stop calls that have been accepted/rejected + /// on other devices or components (e.g. incoming callScreen, CallKitService) private func subscribeToCallEvents() { callEventsSubscription?.cancel() callEventsSubscription = nil @@ -435,7 +445,7 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { ) -> (UUID, CXCallUpdate) { let update = CXCallUpdate() let idComponents = cid.components(separatedBy: ":") - let uuid = UUID() + let uuid = uuidFactory.get() if idComponents.count >= 2, let call = streamVideo?.call(callType: idComponents[0], callId: idComponents[1]) { storage[uuid] = .init(call: call, callUUID: uuid) } diff --git a/Sources/StreamVideo/Utils/StateMachine/CallStateMachine/Stages/StreamCallStateMachine+RejectingStage.swift b/Sources/StreamVideo/Utils/StateMachine/CallStateMachine/Stages/StreamCallStateMachine+RejectingStage.swift index 2cd33643f..383dff1f4 100644 --- a/Sources/StreamVideo/Utils/StateMachine/CallStateMachine/Stages/StreamCallStateMachine+RejectingStage.swift +++ b/Sources/StreamVideo/Utils/StateMachine/CallStateMachine/Stages/StreamCallStateMachine+RejectingStage.swift @@ -57,6 +57,9 @@ extension StreamCallStateMachine.Stage { Task { do { let response = try await actionBlock() + if let cId = call?.cId { + callCache.remove(for: cId) + } try transition?(.rejected(call, response: response)) } catch { do { diff --git a/Sources/StreamVideo/Utils/UUIDProviding/StreamUUIDFactory.swift b/Sources/StreamVideo/Utils/UUIDProviding/StreamUUIDFactory.swift new file mode 100644 index 000000000..1ffbb897e --- /dev/null +++ b/Sources/StreamVideo/Utils/UUIDProviding/StreamUUIDFactory.swift @@ -0,0 +1,24 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Foundation + +protocol UUIDProviding { + func get() -> UUID +} + +enum UUIDProviderKey: InjectionKey { + static var currentValue: UUIDProviding = StreamUUIDFactory() +} + +extension InjectedValues { + var uuidFactory: UUIDProviding { + get { Self[UUIDProviderKey.self] } + set { Self[UUIDProviderKey.self] = newValue } + } +} + +struct StreamUUIDFactory: UUIDProviding { + func get() -> UUID { .init() } +} diff --git a/Sources/StreamVideo/Utils/Utils.swift b/Sources/StreamVideo/Utils/Utils.swift index 9aea4217e..f55f2b400 100644 --- a/Sources/StreamVideo/Utils/Utils.swift +++ b/Sources/StreamVideo/Utils/Utils.swift @@ -21,7 +21,7 @@ func postNotification( ) } -func callCid(from callId: String, callType: String) -> String { +public func callCid(from callId: String, callType: String) -> String { "\(callType):\(callId)" } diff --git a/Sources/StreamVideoSwiftUI/CallView/Participants/CallParticipantsInfoViewModel.swift b/Sources/StreamVideoSwiftUI/CallView/Participants/CallParticipantsInfoViewModel.swift index 5e19c8a09..9f1a27fd1 100644 --- a/Sources/StreamVideoSwiftUI/CallView/Participants/CallParticipantsInfoViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallView/Participants/CallParticipantsInfoViewModel.swift @@ -52,7 +52,7 @@ class CallParticipantsInfoViewModel: ObservableObject { isDestructive: false ) - private var call: Call? + private weak var call: Call? var inviteParticipantsButtonShown: Bool { call?.currentUserHasCapability(.updateCallMember) == true diff --git a/Sources/StreamVideoSwiftUI/CallViewModel.swift b/Sources/StreamVideoSwiftUI/CallViewModel.swift index 732ab4395..efc6fd977 100644 --- a/Sources/StreamVideoSwiftUI/CallViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallViewModel.swift @@ -10,7 +10,7 @@ import SwiftUI // View model that provides methods for views that present a call. @MainActor open class CallViewModel: ObservableObject { - + @Injected(\.streamVideo) var streamVideo @Injected(\.pictureInPictureAdapter) var pictureInPictureAdapter @Injected(\.callAudioRecorder) var audioRecorder @@ -18,12 +18,13 @@ open class CallViewModel: ObservableObject { /// Provides access to the current call. @Published public private(set) var call: Call? { didSet { + guard call?.cId != oldValue?.cId else { return } pictureInPictureAdapter.call = call lastLayoutChange = Date() participantUpdates = call?.state.$participantsMap .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] in self?.callParticipants = $0 }) - + blockedUserUpdates = call?.state.$blockedUserIds .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] blockedUserIds in @@ -67,14 +68,14 @@ 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() } } - + /// Optional, has a value if there was an error. You can use it to display more detailed error messages to the users. public var error: Error? { didSet { @@ -86,13 +87,13 @@ open class CallViewModel: ObservableObject { } } } - + /// Controls the display of toast messages. @Published public var toast: Toast? - + /// If the `error` property has a value, it's true. You can use it to control the visibility of an alert presented to the user. @Published public var errorAlertShown = false - + /// Whether the list of participants is shown during the call. @Published public var participantsShown = false @@ -101,7 +102,7 @@ open class CallViewModel: ObservableObject { /// List of the outgoing call members. @Published public var outgoingCallMembers = [Member]() - + /// Dictionary of the call participants. @Published public private(set) var callParticipants = [String: CallParticipant]() { didSet { @@ -110,32 +111,32 @@ open class CallViewModel: ObservableObject { checkCallSettingsForCurrentUser() } } - + /// Contains info about a participant event. It's reset to nil after 2 seconds. @Published public var participantEvent: ParticipantEvent? - + /// Provides information about the current call settings, such as the camera position and whether there's an audio and video turned on. @Published public internal(set) var callSettings: CallSettings { didSet { localCallSettingsChange = true } } - + /// Whether the call is in minimized mode. @Published public var isMinimized = false - + /// `false` by default. It becomes `true` when the current user's local video is shown as a primary view. @Published public var localVideoPrimary = false - + /// Whether the UI elements, such as the call controls should be hidden (for example while screensharing). @Published public var hideUIElements = false - + /// A list of the blocked users in the call. @Published public var blockedUsers = [User]() - + /// The current recording state of the call. @Published public var recordingState: RecordingState = .noRecording - + /// The participants layout. @Published public private(set) var participantsLayout: ParticipantsLayout { didSet { @@ -144,7 +145,7 @@ open class CallViewModel: ObservableObject { } } } - + /// A flag controlling whether picture-in-picture should be enabled for the call. Default value is `true`. @Published public var isPictureInPictureEnabled = true @@ -152,7 +153,7 @@ open class CallViewModel: ObservableObject { public var localParticipant: CallParticipant? { call?.state.localParticipant } - + /// Returns the noiseCancellationFilter if available. public var noiseCancellationAudioFilter: AudioFilter? { streamVideo.videoConfig.noiseCancellationFilter } @@ -162,10 +163,10 @@ open class CallViewModel: ObservableObject { private var recordingUpdates: AnyCancellable? private var screenSharingUpdates: AnyCancellable? private var callSettingsUpdates: AnyCancellable? - + private var ringingTimer: Foundation.Timer? private var lastScreenSharingParticipant: CallParticipant? - + private var lastLayoutChange = Date() private var enteringCallTask: Task? private var participantsSortComparators = defaultComparators @@ -191,7 +192,7 @@ open class CallViewModel: ObservableObject { } private var automaticLayoutHandling = true - + public init( participantsLayout: ParticipantsLayout = .grid, callSettings: CallSettings? = nil @@ -221,7 +222,7 @@ open class CallViewModel: ObservableObject { } } } - + /// Toggles the state of the microphone (muted vs unmuted). public func toggleMicrophoneEnabled() { guard let call = call else { @@ -237,7 +238,7 @@ open class CallViewModel: ObservableObject { } } } - + /// Toggles the camera position (front vs back). public func toggleCameraPosition() { guard let call = call, callSettings.videoOn else { @@ -253,7 +254,7 @@ open class CallViewModel: ObservableObject { } } } - + /// Enables or disables the audio output. public func toggleAudioOutput() { guard let call = call else { @@ -273,7 +274,7 @@ open class CallViewModel: ObservableObject { } } } - + /// Enables or disables the speaker. public func toggleSpeaker() { guard let call = call else { @@ -325,7 +326,7 @@ open class CallViewModel: ObservableObject { } } } - + /// Joins an existing call with the provided info. /// - Parameters: /// - callType: the type of the call. @@ -334,7 +335,7 @@ open class CallViewModel: ObservableObject { callingState = .joining enterCall(callType: callType, callId: callId, members: []) } - + /// Enters into a lobby before joining a call. /// - Parameters: /// - callType: the type of the call. @@ -355,7 +356,7 @@ open class CallViewModel: ObservableObject { } } } - + /// Accepts the call with the provided call id and type. /// - Parameters: /// - callType: the type of the call. @@ -373,7 +374,7 @@ open class CallViewModel: ObservableObject { } } } - + /// Rejects the call with the provided call id and type. /// - Parameters: /// - callType: the type of the call. @@ -385,7 +386,7 @@ open class CallViewModel: ObservableObject { self.callingState = .idle } } - + /// Changes the track visibility for a participant (not visible if they go off-screen). /// - Parameters: /// - participant: the participant whose track visibility would be changed. @@ -395,7 +396,7 @@ open class CallViewModel: ObservableObject { await call?.changeTrackVisibility(for: participant, isVisible: isVisible) } } - + /// Updates the track size for the provided participant. /// - Parameters: /// - trackSize: the size of the track. @@ -406,19 +407,19 @@ open class CallViewModel: ObservableObject { await call?.updateTrackSize(trackSize, for: participant) } } - + public func startScreensharing(type: ScreensharingType) { Task { try await call?.startScreensharing(type: type) } } - + public func stopScreensharing() { Task { try await call?.stopScreensharing() } } - + /// Hangs up from the active call. public func hangUp() { if callingState == .outgoing { @@ -430,20 +431,20 @@ open class CallViewModel: ObservableObject { leaveCall() } } - + /// Sets a video filter for the current call. /// - Parameter videoFilter: the video filter to be set. public func setVideoFilter(_ videoFilter: VideoFilter?) { call?.setVideoFilter(videoFilter) } - + /// Updates the participants layout. /// - Parameter participantsLayout: the new participants layout. public func update(participantsLayout: ParticipantsLayout) { automaticLayoutHandling = false self.participantsLayout = participantsLayout } - + public func setActiveCall(_ call: Call?) { if let call { callingState = .inCall @@ -459,9 +460,9 @@ open class CallViewModel: ObservableObject { public func update(participantsSortComparators: [StreamSortComparator]) { self.participantsSortComparators = participantsSortComparators } - + // MARK: - private - + /// Leaves the current call. private func leaveCall() { log.debug("Leaving call") @@ -487,7 +488,7 @@ open class CallViewModel: ObservableObject { localVideoPrimary = false Task { await audioRecorder.stopRecording() } } - + private func enterCall( call: Call? = nil, callType: String, @@ -521,7 +522,7 @@ open class CallViewModel: ObservableObject { } } } - + private func save(call: Call) { guard enteringCallTask != nil else { call.leave() @@ -532,13 +533,13 @@ open class CallViewModel: ObservableObject { updateCallStateIfNeeded() log.debug("Started call") } - + private func handleRingingEvents() { if callingState != .outgoing { ringingTimer?.invalidate() } } - + private func startTimer(timeout: TimeInterval) { ringingTimer = Foundation.Timer.scheduledTimer( withTimeInterval: timeout, @@ -552,7 +553,7 @@ open class CallViewModel: ObservableObject { } ) } - + private func subscribeToCallEvents() { Task { for await event in streamVideo.subscribe() { @@ -566,14 +567,8 @@ open class CallViewModel: ObservableObject { callingState = .incoming(incomingCall) } } - case let .accepted(callEventInfo): - if callingState == .outgoing { - enterCall(call: call, callType: callEventInfo.type, callId: callEventInfo.callId, members: []) - } else if case .incoming = callingState, - callEventInfo.user?.id == streamVideo.user.id && enteringCallTask == nil { - // Accepted on another device. - callingState = .idle - } + case .accepted: + handleAcceptedEvent(callEvent) case .rejected: handleRejectedEvent(callEvent) case .ended: @@ -593,9 +588,10 @@ open class CallViewModel: ObservableObject { return } self.participantEvent = participantEvent - if participantEvent.action == .leave && - callParticipants.count == 1 - && call?.state.session?.acceptedBy.isEmpty == false { + if participantEvent.action == .leave, + callParticipants.count == 1, + call?.state.session?.acceptedBy.isEmpty == false + { leaveCall() } else { // The event is shown for 2 seconds. @@ -606,22 +602,61 @@ open class CallViewModel: ObservableObject { } } } - + + private func handleAcceptedEvent(_ callEvent: CallEvent) { + guard case let .accepted(event) = callEvent else { + return + } + + switch callingState { + case .incoming where event.user?.id == streamVideo.user.id: + callingState = .idle + case .outgoing where call?.cId == event.callCid: + enterCall( + call: call, + callType: event.type, + callId: event.callId, + members: [] + ) + default: + break + } + } + private func handleRejectedEvent(_ callEvent: CallEvent) { - if case let .rejected(event) = callEvent, event.callId == call?.callId { + guard case let .rejected(event) = callEvent else { + return + } + + switch callingState { + case let .incoming(incomingCall) where event.callCid == callCid(from: incomingCall.id, callType: incomingCall.type): + /// If the call that was rejected is the incoming call we are presenting, then we reject + /// and set the activeCall to the current one in order to reset the callingState to + /// inCall. + Task { + _ = try? await streamVideo + .call(callType: incomingCall.type, callId: incomingCall.id) + .reject() + setActiveCall(call) + } + case .outgoing where call?.cId == event.callCid: + guard let outgoingCall = call else { + return + } let outgoingMembersCount = outgoingCallMembers.filter { $0.id != streamVideo.user.id }.count - let rejections = call?.state.session?.rejectedBy.count ?? 0 - let accepted = call?.state.session?.acceptedBy.count ?? 0 - + let rejections = outgoingCall.state.session?.rejectedBy.count ?? 0 + let accepted = outgoingCall.state.session?.acceptedBy.count ?? 0 if accepted == 0, rejections >= outgoingMembersCount { Task { - _ = try? await call?.reject() + _ = try? await outgoingCall.reject() leaveCall() } } + default: + break } } - + private func updateCallStateIfNeeded() { if callingState == .outgoing { if !callParticipants.isEmpty { @@ -639,7 +674,7 @@ open class CallViewModel: ObservableObject { } } } - + private func checkCallSettingsForCurrentUser() { guard let localParticipant = localParticipant, // Skip updates for the initial period while the connection is established. diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index e41595800..7f59dcd3e 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -178,6 +178,7 @@ 407F29FF2AA6011500C3EAF8 /* MemoryLogViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861E2AA0A21800FF5AF4 /* MemoryLogViewer.swift */; }; 407F2A002AA6011B00C3EAF8 /* LogQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861B2AA0A11500FF5AF4 /* LogQueue.swift */; }; 408679F72BD12F1000D027E0 /* AudioFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408679F62BD12F1000D027E0 /* AudioFilter.swift */; }; + 4089378B2C062B17000EEB69 /* StreamUUIDFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4089378A2C062B17000EEB69 /* StreamUUIDFactory.swift */; }; 408CE0F32BD905920052EC3A /* Models+Sendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408CE0F22BD905920052EC3A /* Models+Sendable.swift */; }; 408CE0F72BD95EB60052EC3A /* VideoConfig+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408CE0F62BD95EB60052EC3A /* VideoConfig+Dummy.swift */; }; 408CE0F82BD95F170052EC3A /* VideoConfig+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408CE0F62BD95EB60052EC3A /* VideoConfig+Dummy.swift */; }; @@ -1250,6 +1251,7 @@ 407AF7192B6163DD00E9E3E7 /* StreamMediaDurationFormatter_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamMediaDurationFormatter_Tests.swift; sourceTree = ""; }; 407D5D3C2ACEF0C500B5044E /* VisibilityThresholdModifier_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityThresholdModifier_Tests.swift; sourceTree = ""; }; 408679F62BD12F1000D027E0 /* AudioFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFilter.swift; sourceTree = ""; }; + 4089378A2C062B17000EEB69 /* StreamUUIDFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamUUIDFactory.swift; sourceTree = ""; }; 408CE0F22BD905920052EC3A /* Models+Sendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models+Sendable.swift"; sourceTree = ""; }; 408CE0F62BD95EB60052EC3A /* VideoConfig+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoConfig+Dummy.swift"; sourceTree = ""; }; 408D29A12B6D209700885473 /* SnapshotViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotViewModifier.swift; sourceTree = ""; }; @@ -2520,6 +2522,14 @@ path = AudioFilter; sourceTree = ""; }; + 408937892C062B0B000EEB69 /* UUIDProviding */ = { + isa = PBXGroup; + children = ( + 4089378A2C062B17000EEB69 /* StreamUUIDFactory.swift */, + ); + path = UUIDProviding; + sourceTree = ""; + }; 408D29A02B6D208700885473 /* Snapshot */ = { isa = PBXGroup; children = ( @@ -3750,6 +3760,7 @@ 84AF64D3287C79220012A503 /* Utils */ = { isa = PBXGroup; children = ( + 408937892C062B0B000EEB69 /* UUIDProviding */, 403FF3DF2BA1D20E0092CE8A /* UnfairQueue */, 40FB150D2BF77CA200D5E580 /* StateMachine */, 40FB15082BF74C0A00D5E580 /* CallCache */, @@ -5146,6 +5157,7 @@ 84DC38A529ADFCFD00946713 /* SFUResponse.swift in Sources */, 84A737D128F4716E001A6769 /* events.pb.swift in Sources */, 84DC38C129ADFCFD00946713 /* CallRequest.swift in Sources */, + 4089378B2C062B17000EEB69 /* StreamUUIDFactory.swift in Sources */, 84DC38A829ADFCFD00946713 /* QueryCallsResponse.swift in Sources */, 840F59912A77FDCB00EF3EB2 /* HLSSettingsRequest.swift in Sources */, 842B8E1B2A2DFED900863A87 /* CallSessionStartedEvent.swift in Sources */, diff --git a/StreamVideoTests/CallKit/CallKitServiceTests.swift b/StreamVideoTests/CallKit/CallKitServiceTests.swift index 4f6f0e7a9..3eb7725b5 100644 --- a/StreamVideoTests/CallKit/CallKitServiceTests.swift +++ b/StreamVideoTests/CallKit/CallKitServiceTests.swift @@ -10,6 +10,7 @@ import XCTest final class CallKitServiceTests: XCTestCase, @unchecked Sendable { private lazy var subject: CallKitService! = .init() + private lazy var uuidFactory: MockUUIDFactory! = .init() private lazy var callController: MockCXCallController! = .init() private lazy var callProvider: MockCXProvider! = .init() private lazy var user: User! = .init(id: "test") @@ -34,6 +35,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { override func setUp() { super.setUp() + InjectedValues[\.uuidFactory] = uuidFactory subject.callController = callController subject.callProvider = callProvider callProvider.setDelegate(subject, queue: nil) @@ -41,6 +43,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { override func tearDown() { subject = nil + uuidFactory = nil callController = nil callProvider = nil user = nil @@ -72,9 +75,13 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { // Then waitForExpectations(timeout: defaultTimeout, handler: nil) XCTAssertNil(completionError) - XCTAssertTrue(callProvider.reportNewIncomingCallCalled) - XCTAssertEqual(callProvider.reportNewIncomingCallUpdate?.localizedCallerName, localizedCallerName) - XCTAssertEqual(callProvider.reportNewIncomingCallUpdate?.remoteHandle?.value, callerId) + + guard case let .reportNewIncomingCall(_, update, _) = callProvider.invocations.last else { + return XCTFail() + } + + XCTAssertEqual(update.localizedCallerName, localizedCallerName) + XCTAssertEqual(update.remoteHandle?.value, callerId) } func test_reportIncomingCall_streamVideoIsNil_noCallWasCreatedAndNoActionIsBeingPerformed() async throws { @@ -242,7 +249,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { callerId: callerId ) { _ in } - try await assertRequestTransaction(CXAnswerCallAction.self) { + await assertReportCallEnded(.answeredElsewhere) { subject.callAccepted( .dummy( call: .dummy( @@ -269,7 +276,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { callerId: callerId ) { _ in } - try await assertRequestTransaction(CXEndCallAction.self) { + await assertReportCallEnded(.declinedElsewhere) { subject.callRejected( .dummy( call: .dummy(id: callId), @@ -282,6 +289,8 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { @MainActor func test_callRejected_whileInCall_expectedTransactionWasRequestedAndRemainsInCall() async throws { + let firstCallUUID = UUID() + uuidFactory.getResult = firstCallUUID stubCall(response: defaultGetCallResponse) subject.streamVideo = mockedStreamVideo @@ -291,18 +300,20 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { callerId: callerId ) { _ in } - try await assertRequestTransaction(CXAnswerCallAction.self) { - subject.callAccepted( - .dummy( - call: .dummy(id: callId), - callCid: cid - ) + subject.provider( + callProvider, + perform: CXAnswerCallAction( + call: firstCallUUID ) - } + ) + + await waitExpectation() XCTAssertEqual(subject.storage.count, 1) // Stub with the new call + let secondCallUUID = UUID() + uuidFactory.getResult = secondCallUUID let secondCallId = "default:test-call-2" stubCall(overrideCallId: secondCallId, response: .dummy( call: .dummy( @@ -321,15 +332,12 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { XCTAssertEqual(subject.storage.count, 2) - try await assertRequestTransaction(CXEndCallAction.self) { - subject.callRejected( - .dummy( - call: .dummy(id: secondCallId), - callCid: callCid(from: secondCallId, callType: .default), - user: .dummy(id: user.id) - ) + subject.provider( + callProvider, + perform: CXEndCallAction( + call: secondCallUUID ) - } + ) await fulfillment { [weak subject] in subject?.storage.count == 1 } @@ -358,38 +366,39 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { @MainActor func test_callParticipantLeft_participantsLeftMoreThanOne_callWasNotEnded() async throws { + let firstCallUUID = UUID() + uuidFactory.getResult = firstCallUUID let call = stubCall(response: defaultGetCallResponse) subject.streamVideo = mockedStreamVideo - try await assertRequestTransaction(CXAnswerCallAction.self) { - subject.reportIncomingCall( - cid, - localizedCallerName: localizedCallerName, - callerId: callerId - ) { _ in } + subject.reportIncomingCall( + cid, + localizedCallerName: localizedCallerName, + callerId: callerId + ) { _ in } - let waitExpectation = expectation(description: "Wait expectation.") - waitExpectation.isInverted = true - wait(for: [waitExpectation], timeout: 2) + await waitExpectation(timeout: 2) - subject.callAccepted( - .dummy( - call: .dummy(id: callId), - callCid: cid - ) + // Accept call + subject.provider( + callProvider, + perform: CXAnswerCallAction( + call: firstCallUUID ) - } + ) let callState = CallState() callState.participants = [.dummy(), .dummy()] call.stub(for: \.state, with: callState) try await assertNotRequestTransaction(CXEndCallAction.self) { - subject.callParticipantLeft(.dummy()) + subject.callParticipantLeft(.dummy(callCid: call.cId)) } } @MainActor func test_callParticipantLeft_participantsLeftOnlyOne_callNotEnded() async throws { + let firstCallUUID = UUID() + uuidFactory.getResult = firstCallUUID let call = stubCall( response: .dummy( call: defaultGetCallResponse.call, @@ -400,24 +409,21 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { ) subject.streamVideo = mockedStreamVideo - try await assertRequestTransaction(CXAnswerCallAction.self) { - subject.reportIncomingCall( - cid, - localizedCallerName: localizedCallerName, - callerId: callerId - ) { _ in } - - let waitExpectation = expectation(description: "Wait expectation.") - waitExpectation.isInverted = true - wait(for: [waitExpectation], timeout: 2) + subject.reportIncomingCall( + cid, + localizedCallerName: localizedCallerName, + callerId: callerId + ) { _ in } - subject.callAccepted(.dummy(call: .dummy(id: callId), callCid: cid)) - subject.provider(callProvider, perform: CXAnswerCallAction(call: UUID())) + await waitExpectation(timeout: 2) - let waitExpectation2 = expectation(description: "Wait expectation.") - waitExpectation2.isInverted = true - wait(for: [waitExpectation2], timeout: 2) - } + // Accept call + subject.provider( + callProvider, + perform: CXAnswerCallAction( + call: firstCallUUID + ) + ) let callState = CallState() callState.participants = [.dummy()] @@ -430,6 +436,33 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { // MARK: - Private Helpers + @MainActor + private func assertReportCallEnded( + _ expectedReason: CXCallEndedReason, + actionBlock: @MainActor @Sendable() -> Void, + file: StaticString = #file, + line: UInt = #line + ) async { + callProvider.reset() + + actionBlock() + + await fulfillment(timeout: defaultTimeout, file: file, line: line) { + if case .reportCall = self.callProvider.invocations.last { + return true + } else { + return false + } + } + + guard case let .reportCall(uuid, dateEnded, reason) = callProvider.invocations.last else { + XCTFail(file: file, line: line) + return + } + + XCTAssertEqual(expectedReason, reason, file: file, line: line) + } + @MainActor private func assertRequestTransaction( _ expected: T.Type, @@ -441,7 +474,9 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { actionBlock() - await fulfillment(timeout: defaultTimeout) { self.callController.requestWasCalledWith?.0.actions.first != nil } + await fulfillment(timeout: defaultTimeout, file: file, line: line) { + self.callController.requestWasCalledWith?.0.actions.first != nil + } let action = try XCTUnwrap( callController.requestWasCalledWith?.0.actions.first, @@ -468,15 +503,19 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { file: StaticString = #file, line: UInt = #line ) async throws { + callProvider.reset() + actionBlock() await waitExpectation(timeout: 1, description: "Wait for internal async tasks to complete.") - let action = try XCTUnwrap(callController.requestWasCalledWith?.0.actions.first) - XCTAssertFalse( - action is T, - "Action type is \(String(describing: type(of: action))) instead of \(String(describing: T.self))" - ) + if let record = callController.requestWasCalledWith { + let action = try XCTUnwrap(record.0.actions.first) + XCTAssertFalse( + action is T, + "Action type is \(String(describing: type(of: action))) instead of \(String(describing: T.self))" + ) + } } @MainActor @@ -590,3 +629,11 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { return call } } + +private class MockUUIDFactory: UUIDProviding { + var getResult: UUID? + + func get() -> UUID { + getResult ?? .init() + } +} diff --git a/StreamVideoTests/Utilities/Extensions/XCTestCase+PredicateFulfillment.swift b/StreamVideoTests/Utilities/Extensions/XCTestCase+PredicateFulfillment.swift index 3d1fcf1d9..0387ffe9d 100644 --- a/StreamVideoTests/Utilities/Extensions/XCTestCase+PredicateFulfillment.swift +++ b/StreamVideoTests/Utilities/Extensions/XCTestCase+PredicateFulfillment.swift @@ -10,9 +10,9 @@ extension XCTestCase { func fulfillment( timeout: TimeInterval = defaultTimeout, enforceOrder: Bool = false, - block: @escaping () -> Bool, file: StaticString = #file, - line: UInt = #line + line: UInt = #line, + block: @escaping () -> Bool ) async { let predicate = NSPredicate { _, _ in block() } let waitExpectation = XCTNSPredicateExpectation( diff --git a/StreamVideoTests/Utilities/Mocks/MockCXProvider.swift b/StreamVideoTests/Utilities/Mocks/MockCXProvider.swift index 2fc87ee82..01e266f59 100644 --- a/StreamVideoTests/Utilities/Mocks/MockCXProvider.swift +++ b/StreamVideoTests/Utilities/Mocks/MockCXProvider.swift @@ -6,8 +6,12 @@ import CallKit import Foundation final class MockCXProvider: CXProvider { - var reportNewIncomingCallCalled = false - var reportNewIncomingCallUpdate: CXCallUpdate? + enum Invocation { + case reportNewIncomingCall(uuid: UUID, update: CXCallUpdate, completion: (Error?) -> Void) + case reportCall(uuid: UUID, endedAt: Date?, reason: CXCallEndedReason) + } + + private(set) var invocations: [Invocation] = [] convenience init() { self.init(configuration: .init(localizedName: "test")) @@ -18,8 +22,31 @@ final class MockCXProvider: CXProvider { update: CXCallUpdate, completion: @escaping (Error?) -> Void ) { - reportNewIncomingCallCalled = true - reportNewIncomingCallUpdate = update + invocations.append( + .reportNewIncomingCall( + uuid: UUID, + update: update, + completion: completion + ) + ) completion(nil) } + + override func reportCall( + with UUID: UUID, + endedAt dateEnded: Date?, + reason endedReason: CXCallEndedReason + ) { + invocations.append( + .reportCall( + uuid: UUID, + endedAt: dateEnded, + reason: endedReason + ) + ) + } + + func reset() { + invocations = [] + } }