diff --git a/CHANGELOG.md b/CHANGELOG.md index c3eed3b62..2b6c0206e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Support for custom participant sorting in the Call object. [#438](https://github.com/GetStream/stream-video-swift/pull/438) - Ability to join call in advance with joinAheadTimeSeconds parameter. [#446](https://github.com/GetStream/stream-video-swift/pull/446) +- Missed calls support [#449](https://github.com/GetStream/stream-video-swift/pull/449) +- IncomingCallViewModel has been simplified and the `hideIncomingCallScreen` property as also the `stopTimer` have been removed. [#449](https://github.com/GetStream/stream-video-swift/pull/449) # [1.0.8](https://github.com/GetStream/stream-video-swift/releases/tag/1.0.8) _June 17, 2024_ diff --git a/DemoApp/Sources/Examples/Examples.swift b/DemoApp/Sources/Examples/Examples.swift index 1dc753c86..9ea005370 100644 --- a/DemoApp/Sources/Examples/Examples.swift +++ b/DemoApp/Sources/Examples/Examples.swift @@ -256,14 +256,6 @@ struct CustomIncomingCallView: View { .padding() } .background(Color.white.edgesIgnoringSafeArea(.all)) - .onChange(of: viewModel.hideIncomingCallScreen) { newValue in - if newValue { - callViewModel.rejectCall(callType: callInfo.type, callId: callInfo.id) - } - } - .onDisappear { - viewModel.stopTimer() - } } var callInfo: IncomingCall { diff --git a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/05-incoming-call.swift b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/05-incoming-call.swift index 61a4a03a8..4c4238625 100644 --- a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/05-incoming-call.swift +++ b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/05-incoming-call.swift @@ -80,20 +80,11 @@ fileprivate func content() { } .background(Color.white.edgesIgnoringSafeArea(.all)) - .onChange(of: viewModel.hideIncomingCallScreen) { newValue in - if newValue { - callViewModel.rejectCall(callType: callInfo.type, callId: callInfo.id) - } - } - .onDisappear { - viewModel.stopTimer() - } } var callInfo: IncomingCall { viewModel.callInfo } - } class CustomViewFactory: ViewFactory { diff --git a/Sources/StreamVideo/CallKit/CallKitService.swift b/Sources/StreamVideo/CallKit/CallKitService.swift index fff113ceb..0c6d7ca49 100644 --- a/Sources/StreamVideo/CallKit/CallKitService.swift +++ b/Sources/StreamVideo/CallKit/CallKitService.swift @@ -109,11 +109,22 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { ) log.debug( - "Reporting VoIP incoming call with callUUID:\(callUUID) cid:\(cid) callerId:\(callerId) callerName:\(localizedCallerName)." + """ + Reporting VoIP incoming call with + callUUID:\(callUUID) + cid:\(cid) + callerId:\(callerId) + callerName:\(localizedCallerName) + """ ) guard let streamVideo, let callEntry = storage[callUUID] else { - log.warning("CallKit operation:reportIncomingCall cannot be fulfilled because StreamVideo is nil.") + log.warning( + """ + CallKit operation:reportIncomingCall cannot be fulfilled because + StreamVideo is nil. + """ + ) callEnded(cid) return } @@ -146,15 +157,23 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { } else { log.debug( """ - Rejecting VoIP incoming call with callUUID:\(callUUID) - cid:\(cid) callerId:\(callerId) callerName:\(localizedCallerName) - as it has been handled. + Rejecting VoIP incoming call as it has been handled. + callUUID:\(callUUID) + cid:\(cid) + callerId:\(callerId) + callerName:\(localizedCallerName) """ ) callEnded(cid) } } catch { - log.error("Failed to report incoming call with callId:\(callId) callType:\(callType)") + log.error( + """ + Failed to report incoming call with + callId:\(callId) + callType:\(callType) + """ + ) callEnded(cid) } } @@ -313,12 +332,29 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { } Task { - log - .debug( - "Ending VoIP call with callId:\(stackEntry.call.callId) callType:\(stackEntry.call.callType) callerId:\(String(describing: stackEntry.createdBy?.id))." - ) + log.debug( + """ + Ending VoIP call with + callId:\(stackEntry.call.callId) + callType:\(stackEntry.call.callType) + callerId:\(String(describing: stackEntry.createdBy?.id)) + """ + ) do { - try await stackEntry.call.reject() + let rejectionReason = streamVideo? + .rejectionReasonProvider + .reason( + for: stackEntry.call.cId, + ringTimeout: false + ) + log.debug( + """ + Rejecting with reason: \(rejectionReason ?? "nil") + call:\(stackEntry.call.callId) + callType: \(stackEntry.call.callType) + """ + ) + try await stackEntry.call.reject(reason: rejectionReason) } catch { log.error(error) } @@ -396,7 +432,12 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { callEventsSubscription = nil guard let streamVideo else { - log.warning("CallKit operation:\(#function) cannot be fulfilled because StreamVideo is nil.") + log.warning( + """ + CallKit operation:\(#function) cannot be fulfilled because + StreamVideo is nil. + """ + ) return } @@ -454,7 +495,12 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { let update = CXCallUpdate() let idComponents = cid.components(separatedBy: ":") let uuid = uuidFactory.get() - if idComponents.count >= 2, let call = streamVideo?.call(callType: idComponents[0], callId: idComponents[1]) { + 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/Models/Extensions/RejectCallRequest+Reason.swift b/Sources/StreamVideo/Models/Extensions/RejectCallRequest+Reason.swift new file mode 100644 index 000000000..faed640b9 --- /dev/null +++ b/Sources/StreamVideo/Models/Extensions/RejectCallRequest+Reason.swift @@ -0,0 +1,25 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// An extension of `RejectCallRequest` that defines various reasons for rejecting a call. +extension RejectCallRequest { + + /// Enum containing possible reasons for rejecting a call. + public enum Reason { + + /// Indicates that the callee is busy and cannot accept the call. + public static let busy = "busy" + + /// Indicates that the callee intentionally declines the call. + public static let decline = "decline" + + /// Indicates that the caller cancels the call. + public static let cancel = "cancel" + + /// Indicates that the callee didn't answer the call in a given time amount. + public static let timeout = "timeout" + } +} diff --git a/Sources/StreamVideo/StreamVideo.swift b/Sources/StreamVideo/StreamVideo.swift index e702a32f4..b633d3ea4 100644 --- a/Sources/StreamVideo/StreamVideo.swift +++ b/Sources/StreamVideo/StreamVideo.swift @@ -48,6 +48,9 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { /// Provides information regarding hardware-acceleration capabilities (neuralEngine) on device. public var isHardwareAccelerationAvailable: Bool { neuralEngineExists } + /// A protocol that provides a method to determine the rejection reason for a call. + public lazy var rejectionReasonProvider: RejectionReasonProviding = StreamRejectionReasonProvider(self) + var token: UserToken private var tokenProvider: UserTokenProvider @@ -187,8 +190,12 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { log.error("Error connecting as guest", error: error) } } else { - try Task.checkCancellation() - try await self.connectUser(isInitial: true) + do { + try Task.checkCancellation() + try await self.connectUser(isInitial: true) + } catch { + log.error(error) + } } } } diff --git a/Sources/StreamVideo/Utils/RejectionReasonProvider/RejectionReasonProvider.swift b/Sources/StreamVideo/Utils/RejectionReasonProvider/RejectionReasonProvider.swift new file mode 100644 index 000000000..421aba751 --- /dev/null +++ b/Sources/StreamVideo/Utils/RejectionReasonProvider/RejectionReasonProvider.swift @@ -0,0 +1,67 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +/// A protocol that provides a method to determine the rejection reason for a call. +public protocol RejectionReasonProviding { + + /// Determines the rejection reason for a call with the specified call ID. + /// + /// - Parameters: + /// - callCid: The call ID to evaluate. + /// - ringTimeout: Informs the provider that the rejection is because of the ringing call timeout. + /// + /// - Returns: A string representing the rejection reason, or `nil` if there is no reason to reject + /// the call. + /// + /// - Note: ``ringTimeout`` being true, has an effect **only** when it's set from the side of + /// the caller when the callee doesn't reply the ringing call in the amount of time set on the dashboard. + func reason(for callCid: String, ringTimeout: Bool) -> String? +} + +/// A provider that determines the rejection reason for a call based on its state. +final class StreamRejectionReasonProvider: RejectionReasonProviding { + + /// The stream video associated with this provider. + private weak var streamVideo: StreamVideo? + + /// A container for managing cancellable subscriptions. + private let cancellables: DisposableBag = .init() + + init(_ streamVideo: StreamVideo) { + self.streamVideo = streamVideo + } + + // MARK: - RejectionReasonProviding + + @MainActor + func reason( + for callCid: String, + ringTimeout: Bool + ) -> String? { + let activeCall = streamVideo?.state.activeCall + + guard + let rejectingCall = streamVideo?.state.ringingCall, + rejectingCall.cId == callCid + else { + return nil + } + + let isUserBusy = activeCall != nil + let isUserRejectingOutgoingCall = rejectingCall.state.createdBy?.id == streamVideo?.user.id + + if isUserBusy { + return RejectCallRequest.Reason.busy + } else if isUserRejectingOutgoingCall { + return ringTimeout + ? RejectCallRequest.Reason.timeout + : RejectCallRequest.Reason.cancel + } else { + return RejectCallRequest.Reason.decline + } + } +} diff --git a/Sources/StreamVideoSwiftUI/CallViewModel.swift b/Sources/StreamVideoSwiftUI/CallViewModel.swift index e91899490..825296f6e 100644 --- a/Sources/StreamVideoSwiftUI/CallViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallViewModel.swift @@ -430,10 +430,24 @@ open class CallViewModel: ObservableObject { /// - Parameters: /// - callType: the type of the call. /// - callId: the id of the call. - public func rejectCall(callType: String, callId: String) { + public func rejectCall( + callType: String, + callId: String + ) { Task { let call = streamVideo.call(callType: callType, callId: callId) - _ = try? await call.reject() + let rejectionReason = streamVideo + .rejectionReasonProvider + .reason(for: call.cId, ringTimeout: false) + log.debug( + """ + Rejecting with reason: \(rejectionReason ?? "nil") + call:\(call.callId) + callType: \(call.callType) + ringTimeout: \(false) + """ + ) + _ = try? await call.reject(reason: rejectionReason) self.callingState = .idle } } @@ -481,14 +495,7 @@ open class CallViewModel: ObservableObject { /// Hangs up from the active call. public func hangUp() { - if callingState == .outgoing { - Task { - _ = try? await call?.reject() - leaveCall() - } - } else { - leaveCall() - } + handleCallHangUp(ringTimeout: false) } /// Sets a video filter for the current call. @@ -618,15 +625,46 @@ open class CallViewModel: ObservableObject { withTimeInterval: timeout, repeats: false, block: { [weak self] _ in - guard let self = self else { return } - log.debug("Detected ringing timeout, hanging up...") - Task { - await self.hangUp() + Task { @MainActor [weak self] in + guard let self = self else { return } + log.debug("Detected ringing timeout, hanging up...") + handleCallHangUp(ringTimeout: true) } } ) } + private func handleCallHangUp(ringTimeout: Bool = false) { + guard + let call, + callingState == .outgoing + else { + leaveCall() + return + } + + Task { + do { + let rejectionReason = streamVideo + .rejectionReasonProvider + .reason(for: call.cId, ringTimeout: ringTimeout) + log.debug( + """ + Rejecting with reason: \(rejectionReason ?? "nil") + call:\(call.callId) + callType: \(call.callType) + ringTimeout: \(ringTimeout) + """ + ) + try await call.reject(reason: rejectionReason) + } catch { + log.error(error) + } + + leaveCall() + } + } + private func subscribeToCallEvents() { callEventsSubscriptionTask = Task { for await event in streamVideo.subscribe() { diff --git a/Sources/StreamVideoSwiftUI/CallingViews/IncomingCallView.swift b/Sources/StreamVideoSwiftUI/CallingViews/IncomingCallView.swift index 80bedcbbf..f6c5e691c 100644 --- a/Sources/StreamVideoSwiftUI/CallingViews/IncomingCallView.swift +++ b/Sources/StreamVideoSwiftUI/CallingViews/IncomingCallView.swift @@ -43,14 +43,6 @@ public struct IncomingCallView: View { onCallAccepted: onCallAccepted, onCallRejected: onCallRejected ) - .onChange(of: viewModel.hideIncomingCallScreen) { newValue in - if newValue { - onCallRejected(viewModel.callInfo.id) - } - } - .onDisappear { - viewModel.stopTimer() - } } } diff --git a/Sources/StreamVideoSwiftUI/CallingViews/IncomingCallViewModel.swift b/Sources/StreamVideoSwiftUI/CallingViews/IncomingCallViewModel.swift index 51802880b..f4f18e6fd 100644 --- a/Sources/StreamVideoSwiftUI/CallingViews/IncomingCallViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallingViews/IncomingCallViewModel.swift @@ -13,37 +13,11 @@ public class IncomingViewModel: ObservableObject { public private(set) var callInfo: IncomingCall - @Published public var hideIncomingCallScreen = false - var callParticipants: [Member] { callInfo.members.filter { $0.id != streamVideo.user.id } } - private var ringingTimer: Foundation.Timer? - public init(callInfo: IncomingCall) { self.callInfo = callInfo - startTimer(timeout: callInfo.timeout) - } - - private func startTimer(timeout: TimeInterval) { - ringingTimer = Foundation.Timer.scheduledTimer( - withTimeInterval: timeout, - repeats: false, - block: { [weak self] _ in - guard let self = self else { return } - log.debug("Detected ringing timeout, hanging up...") - Task { - await MainActor.run { - self.hideIncomingCallScreen = true - } - } - } - ) - } - - public func stopTimer() { - ringingTimer?.invalidate() - ringingTimer = nil } } diff --git a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift b/Sources/StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift index 6ae5fbfb5..805acfeb8 100644 --- a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift +++ b/Sources/StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift @@ -37,13 +37,5 @@ public struct IncomingCallView_iOS13: View { onCallAccepted: onCallAccepted, onCallRejected: onCallRejected ) - .onReceive(viewModel.$hideIncomingCallScreen, perform: { value in - if value { - onCallRejected(viewModel.callInfo.id) - } - }) - .onDisappear { - viewModel.stopTimer() - } } } diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index 6baa0bbe2..51639c6a5 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -275,6 +275,9 @@ 40B499CE2AC1AA0900A53B60 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4030E59F2A9DF5BD003E8CBA /* AppEnvironment.swift */; }; 40B713692A275F1400D1FE67 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8456E6C5287EB55F004E180E /* AppState.swift */; }; 40C2B5B62C2B605A00EC2C2D /* DisposableBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C2B5B52C2B605A00EC2C2D /* DisposableBag.swift */; }; + 40C2B5BB2C2C41DA00EC2C2D /* RejectCallRequest+Reason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C2B5BA2C2C41DA00EC2C2D /* RejectCallRequest+Reason.swift */; }; + 40C2B5BE2C2C448200EC2C2D /* RejectionReasonProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C2B5BD2C2C448200EC2C2D /* RejectionReasonProvider.swift */; }; + 40C2B5C62C2D7AED00EC2C2D /* RejectionReasonProvider_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C2B5C52C2D7AED00EC2C2D /* RejectionReasonProvider_Tests.swift */; }; 40C4DF482C1C2BFC0035DBC2 /* LastParticipantAutoLeavePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C4DF412C1C215E0035DBC2 /* LastParticipantAutoLeavePolicy.swift */; }; 40C4DF492C1C2C210035DBC2 /* Publisher+WeakAssign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C4DF432C1C261D0035DBC2 /* Publisher+WeakAssign.swift */; }; 40C4DF4B2C1C2C330035DBC2 /* ParticipantAutoLeavePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C4DF4A2C1C2C330035DBC2 /* ParticipantAutoLeavePolicy.swift */; }; @@ -1344,6 +1347,9 @@ 40B499C92AC1A5E100A53B60 /* OSLogDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogDestination.swift; sourceTree = ""; }; 40B499CB2AC1A90F00A53B60 /* DeeplinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkTests.swift; sourceTree = ""; }; 40C2B5B52C2B605A00EC2C2D /* DisposableBag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposableBag.swift; sourceTree = ""; }; + 40C2B5BA2C2C41DA00EC2C2D /* RejectCallRequest+Reason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RejectCallRequest+Reason.swift"; sourceTree = ""; }; + 40C2B5BD2C2C448200EC2C2D /* RejectionReasonProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectionReasonProvider.swift; sourceTree = ""; }; + 40C2B5C52C2D7AED00EC2C2D /* RejectionReasonProvider_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectionReasonProvider_Tests.swift; sourceTree = ""; }; 40C4DF412C1C215E0035DBC2 /* LastParticipantAutoLeavePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastParticipantAutoLeavePolicy.swift; sourceTree = ""; }; 40C4DF432C1C261D0035DBC2 /* Publisher+WeakAssign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+WeakAssign.swift"; sourceTree = ""; }; 40C4DF4A2C1C2C330035DBC2 /* ParticipantAutoLeavePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantAutoLeavePolicy.swift; sourceTree = ""; }; @@ -2829,6 +2835,30 @@ path = DisposableBag; sourceTree = ""; }; + 40C2B5B92C2C41CF00EC2C2D /* Extensions */ = { + isa = PBXGroup; + children = ( + 40C2B5BA2C2C41DA00EC2C2D /* RejectCallRequest+Reason.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 40C2B5BC2C2C447700EC2C2D /* RejectionReasonProvider */ = { + isa = PBXGroup; + children = ( + 40C2B5BD2C2C448200EC2C2D /* RejectionReasonProvider.swift */, + ); + path = RejectionReasonProvider; + sourceTree = ""; + }; + 40C2B5C42C2D7ADE00EC2C2D /* RejectionReasonProvider */ = { + isa = PBXGroup; + children = ( + 40C2B5C52C2D7AED00EC2C2D /* RejectionReasonProvider_Tests.swift */, + ); + path = RejectionReasonProvider; + sourceTree = ""; + }; 40C4DF402C1C21360035DBC2 /* ParticipantAutoLeavePolicy */ = { isa = PBXGroup; children = ( @@ -3404,6 +3434,7 @@ 842747F429EEDACB00E063AD /* Utils */ = { isa = PBXGroup; children = ( + 40C2B5C42C2D7ADE00EC2C2D /* RejectionReasonProvider */, 40C4DF4E2C1C41470035DBC2 /* ParticipantAutoLeavePolicy */, 403FB14A2BFE14690047A696 /* StateMachine */, 8490031829D2E0DF00AD9BB4 /* Sorting_Tests.swift */, @@ -3603,6 +3634,7 @@ 8456E6D9287EC46D004E180E /* Models */ = { isa = PBXGroup; children = ( + 40C2B5B92C2C41CF00EC2C2D /* Extensions */, 84AF64D1287C78E70012A503 /* User.swift */, 84AF64D6287C79610012A503 /* Token.swift */, 84C2997828784B180034B735 /* CallType.swift */, @@ -3854,6 +3886,7 @@ 84AF64D3287C79220012A503 /* Utils */ = { isa = PBXGroup; children = ( + 40C2B5BC2C2C447700EC2C2D /* RejectionReasonProvider */, 40C2B5B42C2B604800EC2C2D /* DisposableBag */, 40C4DF402C1C21360035DBC2 /* ParticipantAutoLeavePolicy */, 408937892C062B0B000EEB69 /* UUIDProviding */, @@ -5199,6 +5232,7 @@ 84DCA20E2A3885FE000C3411 /* Permissions.swift in Sources */, 842E70D82B91BE1700D2D68B /* StatsOptions.swift in Sources */, 84BAD77E2A6BFFB200733156 /* BroadcastSampleHandler.swift in Sources */, + 40C2B5BB2C2C41DA00EC2C2D /* RejectCallRequest+Reason.swift in Sources */, 840042C92A6FF9A200917B30 /* BroadcastConstants.swift in Sources */, 84F73854287C1A2D00A363F4 /* InjectedValuesExtensions.swift in Sources */, 841BAA462BD15CDE000C73E4 /* CallStatsReportSummaryResponse.swift in Sources */, @@ -5338,6 +5372,7 @@ 842B8E162A2DFED900863A87 /* CallRingEvent.swift in Sources */, 842B8E2D2A2DFED900863A87 /* StopTranscriptionResponse.swift in Sources */, 400E50532BD2A900008C939E /* StreamAudioFilterCapturePostProcessingModule.swift in Sources */, + 40C2B5BE2C2C448200EC2C2D /* RejectionReasonProvider.swift in Sources */, 842D3B5A29F667660051698A /* ModelResponse.swift in Sources */, 8469593729BB6B4E00134EA0 /* EdgeResponse.swift in Sources */, 841BAA412BD15CDE000C73E4 /* Subsession.swift in Sources */, @@ -5548,6 +5583,7 @@ 84F58B9329EEB53E00010C4C /* EventMiddleware_Mock.swift in Sources */, 842747EC29EED59000E063AD /* JSONDecoder_Tests.swift in Sources */, 841FF5052A5D815700809BBB /* VideoCapturerUtils_Tests.swift in Sources */, + 40C2B5C62C2D7AED00EC2C2D /* RejectionReasonProvider_Tests.swift in Sources */, 403FB15C2BFE22170047A696 /* StreamCallStateMachineStageAcceptingStage_Tests.swift in Sources */, 841FF5172A5EA7F600809BBB /* CallParticipants_Tests.swift in Sources */, 40F017452BBEEE6D00E89FD1 /* UserResponse+Dummy.swift in Sources */, diff --git a/StreamVideoTests/Mock/MockCall.swift b/StreamVideoTests/Mock/MockCall.swift index 9c32215f4..1b336e642 100644 --- a/StreamVideoTests/Mock/MockCall.swift +++ b/StreamVideoTests/Mock/MockCall.swift @@ -15,7 +15,7 @@ final class MockCall: Call, Mockable { case join } - var stubbedProperty: [String: Any] = [:] + var stubbedProperty: [String: Any] var stubbedFunction: [FunctionKey: Any] = [:] override var state: CallState { @@ -23,10 +23,15 @@ final class MockCall: Call, Mockable { set { _ = newValue } } - convenience init( + @MainActor + init( _ source: Call = .dummy() ) { - self.init( + stubbedProperty = [ + MockCall.propertyKey(for: \.state): CallState() + ] + + super.init( callType: source.callType, callId: source.callId, coordinatorClient: source.coordinatorClient, diff --git a/StreamVideoTests/Mock/MockStreamVideo.swift b/StreamVideoTests/Mock/MockStreamVideo.swift index 9780f6f3e..51a42837f 100644 --- a/StreamVideoTests/Mock/MockStreamVideo.swift +++ b/StreamVideoTests/Mock/MockStreamVideo.swift @@ -33,6 +33,11 @@ final class MockStreamVideo: StreamVideo, Mockable { pushNotificationsConfig: PushNotificationsConfig = .default, environment: Environment = .init() ) { + var stubbedProperty = stubbedProperty + if stubbedProperty[MockStreamVideo.propertyKey(for: \.state)] == nil { + stubbedProperty[MockStreamVideo.propertyKey(for: \.state)] = MockStreamVideo.State(user: user) + } + self.stubbedProperty = stubbedProperty self.stubbedFunction = stubbedFunction diff --git a/StreamVideoTests/Utils/RejectionReasonProvider/RejectionReasonProvider_Tests.swift b/StreamVideoTests/Utils/RejectionReasonProvider/RejectionReasonProvider_Tests.swift new file mode 100644 index 000000000..ab158d420 --- /dev/null +++ b/StreamVideoTests/Utils/RejectionReasonProvider/RejectionReasonProvider_Tests.swift @@ -0,0 +1,102 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamVideo +import XCTest + +final class StreamRejectionReasonProviderTests: XCTestCase { + + private lazy var mockStreamVideo: MockStreamVideo! = MockStreamVideo() + private lazy var subject: StreamRejectionReasonProvider! = StreamRejectionReasonProvider(mockStreamVideo) + + override func tearDown() { + subject = nil + mockStreamVideo = nil + super.tearDown() + } + + @MainActor + func test_rejectionReason_givenRingingCallWithMatchingCidAndRingTimeout_whenUserIsBusy_thenReturnsBusyReason() { + let ringingCall = MockCall(.dummy()) + mockStreamVideo.state.activeCall = MockCall(.dummy()) + mockStreamVideo.state.ringingCall = ringingCall + + let reason = subject.reason( + for: ringingCall.cId, + ringTimeout: true + ) + + XCTAssertEqual(reason, RejectCallRequest.Reason.busy) + } + + @MainActor + func test_rejectionReason_givenRingingCallWithMatchingCidAndRingTimeout_whenUserIsRejectingOutgoingCall_thenReturnsTimeoutReason( + ) { + let ringingCall = MockCall(.dummy()) + mockStreamVideo.state.ringingCall = ringingCall + ringingCall.state.createdBy = mockStreamVideo.user + + let reason = subject.reason( + for: ringingCall.cId, + ringTimeout: true + ) + + XCTAssertEqual(reason, RejectCallRequest.Reason.timeout) + } + + @MainActor + func test_rejectionReason_givenRingingCallWithMatchingCidAndNoRingTimeout_whenUserIsRejectingOutgoingCall_thenReturnsCancelReason( + ) { + let ringingCall = MockCall(.dummy()) + mockStreamVideo.state.ringingCall = ringingCall + ringingCall.state.createdBy = mockStreamVideo.user + + let reason = subject.reason( + for: ringingCall.cId, + ringTimeout: false + ) + + XCTAssertEqual(reason, RejectCallRequest.Reason.cancel) + } + + @MainActor + func test_rejectionReason_givenRingingCallWithMatchingCidAndNoRingTimeout_whenUserIsNotBusyAndNotRejectingOutgoingCall_thenReturnsDeclineReason( + ) { + let ringingCall = MockCall(.dummy()) + mockStreamVideo.state.ringingCall = ringingCall + ringingCall.state.createdBy = .dummy() + + let reason = subject.reason( + for: ringingCall.cId, + ringTimeout: false + ) + + XCTAssertEqual(reason, RejectCallRequest.Reason.decline) + } + + @MainActor + func test_rejectionReason_givenNoRingingCallMatchingCid_thenReturnsNil() { + let ringingCall = MockCall(.dummy()) + mockStreamVideo.state.ringingCall = ringingCall + ringingCall.state.createdBy = .dummy() + + let reason = subject.reason( + for: .unique, + ringTimeout: false + ) + + XCTAssertNil(reason) + } + + @MainActor + func test_rejectionReason_givenNoRingingCall_thenReturnsNil() { + let reason = subject.reason( + for: .unique, + ringTimeout: false + ) + + XCTAssertNil(reason) + } +} diff --git a/docusaurus/docs/iOS/05-ui-cookbook/05-incoming-call.mdx b/docusaurus/docs/iOS/05-ui-cookbook/05-incoming-call.mdx index 76fe0596a..e12d73d98 100644 --- a/docusaurus/docs/iOS/05-ui-cookbook/05-incoming-call.mdx +++ b/docusaurus/docs/iOS/05-ui-cookbook/05-incoming-call.mdx @@ -95,14 +95,6 @@ struct CustomIncomingCallView: View { } .background(Color.white.edgesIgnoringSafeArea(.all)) - .onChange(of: viewModel.hideIncomingCallScreen) { newValue in - if newValue { - callViewModel.rejectCall(callType: callInfo.type, callId: callInfo.id) - } - } - .onDisappear { - viewModel.stopTimer() - } } var callInfo: IncomingCall {