diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c4e369ca..30909e514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🐞 Fixed - Fix an issue that was causing the video capturer to no be cleaned up when the call was ended, causing the camera access system indicator to remain on while the `CallEnded` screen is visible. [#636](https://github.com/GetStream/stream-video-swift/pull/636) +- Fix an issue which was not dismissing incoming call screen if the call was accepted on another device. [#640](https://github.com/GetStream/stream-video-swift/pull/640) # [1.15.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.15.0) _January 14, 2025_ diff --git a/Sources/StreamVideoSwiftUI/CallViewModel.swift b/Sources/StreamVideoSwiftUI/CallViewModel.swift index 36c5aac89..2996a2977 100644 --- a/Sources/StreamVideoSwiftUI/CallViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallViewModel.swift @@ -750,8 +750,18 @@ open class CallViewModel: ObservableObject { } switch callingState { - case .incoming where event.user?.id == streamVideo.user.id: - break + case let .incoming(incomingCall) + where event.callCid == callCid(from: incomingCall.id, callType: incomingCall.type) && event.user?.id == streamVideo.user + .id: + /// If the call that was accepted 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 or idle. + Task { + _ = try? await streamVideo + .call(callType: incomingCall.type, callId: incomingCall.id) + .reject() + setActiveCall(call) + } case .outgoing where call?.cId == event.callCid: enterCall( call: call, @@ -773,7 +783,7 @@ open class CallViewModel: ObservableObject { 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. + /// inCall or idle. Task { _ = try? await streamVideo .call(callType: incomingCall.type, callId: incomingCall.id) diff --git a/StreamVideoSwiftUITests/CallViewModel_Tests.swift b/StreamVideoSwiftUITests/CallViewModel_Tests.swift index e9d268457..630897d11 100644 --- a/StreamVideoSwiftUITests/CallViewModel_Tests.swift +++ b/StreamVideoSwiftUITests/CallViewModel_Tests.swift @@ -297,7 +297,281 @@ final class CallViewModel_Tests: StreamVideoTestCase { callViewModel.callingState == .inCall } } - + + @MainActor + func test_incomingCall_acceptedFromSameUserElsewhere_callingStateChangesToIdle() async throws { + // Given + let callViewModel = CallViewModel() + await fulfillment { callViewModel.isSubscribedToCallEvents } + + // When + let event = CallRingEvent( + call: mockResponseBuilder.makeCallResponse(cid: cId), + callCid: cId, + createdAt: Date(), + members: [], + sessionId: "123", + user: UserResponse( + blockedUserIds: [], + createdAt: Date(), + custom: [:], + id: secondUser.userId, + language: "", + role: "user", + teams: [], + updatedAt: Date() + ), + video: true + ) + + let wrapped = WrappedEvent.coordinatorEvent(.typeCallRingEvent(event)) + let eventNotificationCenter = try XCTUnwrap(eventNotificationCenter) + eventNotificationCenter.process(wrapped) + + await fulfillment { + if case .incoming = callViewModel.callingState { + return true + } else { + return false + } + } + + // Then + guard case let .incoming(call) = callViewModel.callingState else { + XCTFail() + return + } + XCTAssert(call.id == callId) + + // When we receive an accept even it means we accepted somewhere else + eventNotificationCenter.process( + .coordinatorEvent( + .typeCallAcceptedEvent( + .dummy( + callCid: cId, + user: firstUser.user.toUserResponse() + ) + ) + ) + ) + + // Then + let callingState = callViewModel.callingState + await fulfillment("CallViewModel.callingState expected:.inCall actual: \(callingState)") { + callViewModel.callingState == .idle + } + } + + @MainActor + func test_incomingCall_sameUserAcceptedAnotherCall_callingStateShouldRemainIncoming() async throws { + // Given + let callViewModel = CallViewModel() + await fulfillment { callViewModel.isSubscribedToCallEvents } + + // When + let event = CallRingEvent( + call: mockResponseBuilder.makeCallResponse(cid: cId), + callCid: cId, + createdAt: Date(), + members: [], + sessionId: "123", + user: UserResponse( + blockedUserIds: [], + createdAt: Date(), + custom: [:], + id: secondUser.userId, + language: "", + role: "user", + teams: [], + updatedAt: Date() + ), + video: true + ) + + let wrapped = WrappedEvent.coordinatorEvent(.typeCallRingEvent(event)) + let eventNotificationCenter = try XCTUnwrap(eventNotificationCenter) + eventNotificationCenter.process(wrapped) + + await fulfillment { + if case .incoming = callViewModel.callingState { + return true + } else { + return false + } + } + + // Then + guard case let .incoming(call) = callViewModel.callingState else { + XCTFail() + return + } + XCTAssert(call.id == callId) + + // When we receive an accept even it means we accepted somewhere else + eventNotificationCenter.process( + .coordinatorEvent( + .typeCallAcceptedEvent( + .dummy( + callCid: "default:\(String.unique)", + user: firstUser.user.toUserResponse() + ) + ) + ) + ) + + // Then + try await XCTAssertWithDelay( + { + switch callViewModel.callingState { + case .incoming: + return true + default: + return false + } + }() + ) + } + + @MainActor + func test_incomingCall_anotherUserAcceptedThisCall_callingStateShouldRemainIncoming() async throws { + // Given + let callViewModel = CallViewModel() + await fulfillment { callViewModel.isSubscribedToCallEvents } + + // When + let event = CallRingEvent( + call: mockResponseBuilder.makeCallResponse(cid: cId), + callCid: cId, + createdAt: Date(), + members: [], + sessionId: "123", + user: UserResponse( + blockedUserIds: [], + createdAt: Date(), + custom: [:], + id: secondUser.userId, + language: "", + role: "user", + teams: [], + updatedAt: Date() + ), + video: true + ) + + let wrapped = WrappedEvent.coordinatorEvent(.typeCallRingEvent(event)) + let eventNotificationCenter = try XCTUnwrap(eventNotificationCenter) + eventNotificationCenter.process(wrapped) + + await fulfillment { + if case .incoming = callViewModel.callingState { + return true + } else { + return false + } + } + + // Then + guard case let .incoming(call) = callViewModel.callingState else { + XCTFail() + return + } + XCTAssert(call.id == callId) + + // When we receive an accept even it means we accepted somewhere else + eventNotificationCenter.process( + .coordinatorEvent( + .typeCallAcceptedEvent( + .dummy( + callCid: cId, + user: secondUser.user.toUserResponse() + ) + ) + ) + ) + + // Then + try await XCTAssertWithDelay( + { + switch callViewModel.callingState { + case .incoming: + return true + default: + return false + } + }() + ) + } + + @MainActor + func test_incomingCall_acceptedAnotherCallElsewhere_callingStateShouldRemainInCall() async throws { + // Given + let callViewModel = CallViewModel() + await fulfillment { callViewModel.isSubscribedToCallEvents } + + // When + let event = CallRingEvent( + call: mockResponseBuilder.makeCallResponse(cid: cId), + callCid: cId, + createdAt: Date(), + members: [], + sessionId: "123", + user: UserResponse( + blockedUserIds: [], + createdAt: Date(), + custom: [:], + id: secondUser.userId, + language: "", + role: "user", + teams: [], + updatedAt: Date() + ), + video: true + ) + + let wrapped = WrappedEvent.coordinatorEvent(.typeCallRingEvent(event)) + let eventNotificationCenter = try XCTUnwrap(eventNotificationCenter) + eventNotificationCenter.process(wrapped) + + await fulfillment { + if case .incoming = callViewModel.callingState { + return true + } else { + return false + } + } + + // Then + guard case let .incoming(call) = callViewModel.callingState else { + XCTFail() + return + } + XCTAssert(call.id == callId) + + // When we receive an accept even it means we accepted somewhere else + eventNotificationCenter.process( + .coordinatorEvent( + .typeCallAcceptedEvent( + .dummy( + callCid: "default:\(String.unique)", + user: secondUser.user.toUserResponse() + ) + ) + ) + ) + + // Then + try await XCTAssertWithDelay( + { + switch callViewModel.callingState { + case .incoming: + return true + default: + return false + } + }() + ) + } + @MainActor func test_incomingCall_rejectCall() async throws { // Given