From b6d542dafc2bca67343f652ce9b0b4e4196447f0 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Thu, 23 Jan 2025 16:34:09 +0200 Subject: [PATCH 1/5] [Fix]Accepting call on another device will sync state with CallViewModel --- .../StreamVideoSwiftUI/CallViewModel.swift | 2 +- .../CallViewModel_Tests.swift | 206 +++++++++++++++++- 2 files changed, 206 insertions(+), 2 deletions(-) diff --git a/Sources/StreamVideoSwiftUI/CallViewModel.swift b/Sources/StreamVideoSwiftUI/CallViewModel.swift index 36c5aac89..5de298928 100644 --- a/Sources/StreamVideoSwiftUI/CallViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallViewModel.swift @@ -751,7 +751,7 @@ open class CallViewModel: ObservableObject { switch callingState { case .incoming where event.user?.id == streamVideo.user.id: - break + rejectCall(callType: event.type, callId: event.callId) case .outgoing where call?.cId == event.callCid: enterCall( call: call, diff --git a/StreamVideoSwiftUITests/CallViewModel_Tests.swift b/StreamVideoSwiftUITests/CallViewModel_Tests.swift index e9d268457..984acffe8 100644 --- a/StreamVideoSwiftUITests/CallViewModel_Tests.swift +++ b/StreamVideoSwiftUITests/CallViewModel_Tests.swift @@ -297,7 +297,211 @@ 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_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 From 64a9c399c23644b792c6675f3d9feca7eaf2830a Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Thu, 23 Jan 2025 16:38:16 +0200 Subject: [PATCH 2/5] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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_ From d1933ce15d16a1e74984b0b33d76c0767f1eec0e Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Fri, 24 Jan 2025 12:25:05 +0200 Subject: [PATCH 3/5] Address feedback --- Sources/StreamVideoSwiftUI/CallViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamVideoSwiftUI/CallViewModel.swift b/Sources/StreamVideoSwiftUI/CallViewModel.swift index 5de298928..99bf9de1f 100644 --- a/Sources/StreamVideoSwiftUI/CallViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallViewModel.swift @@ -751,7 +751,7 @@ open class CallViewModel: ObservableObject { switch callingState { case .incoming where event.user?.id == streamVideo.user.id: - rejectCall(callType: event.type, callId: event.callId) + setActiveCall(call) case .outgoing where call?.cId == event.callCid: enterCall( call: call, From 7e75626a3e467a22c591bfa415870a0bdeae2545 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Fri, 24 Jan 2025 12:39:19 +0200 Subject: [PATCH 4/5] Revert and improve --- Sources/StreamVideoSwiftUI/CallViewModel.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/StreamVideoSwiftUI/CallViewModel.swift b/Sources/StreamVideoSwiftUI/CallViewModel.swift index 99bf9de1f..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: - setActiveCall(call) + 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) From 8356ea5b9e703170747f77c077d20a50003e77a9 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Fri, 24 Jan 2025 12:43:35 +0200 Subject: [PATCH 5/5] Update tests --- .../CallViewModel_Tests.swift | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/StreamVideoSwiftUITests/CallViewModel_Tests.swift b/StreamVideoSwiftUITests/CallViewModel_Tests.swift index 984acffe8..630897d11 100644 --- a/StreamVideoSwiftUITests/CallViewModel_Tests.swift +++ b/StreamVideoSwiftUITests/CallViewModel_Tests.swift @@ -362,6 +362,76 @@ final class CallViewModel_Tests: StreamVideoTestCase { } } + @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