diff --git a/Ring/Ring.xcodeproj/project.pbxproj b/Ring/Ring.xcodeproj/project.pbxproj index 8790435dab7bee8da72b403506574678427e70de..5c2da9aaa7da909663176d8d76e2b1c0888220e8 100644 --- a/Ring/Ring.xcodeproj/project.pbxproj +++ b/Ring/Ring.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXAggregateTarget section */ @@ -290,9 +290,27 @@ 2632D2F12CA5A82F00E60096 /* ImportFromArchiveVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2632D2F02CA5A82F00E60096 /* ImportFromArchiveVM.swift */; }; 2632D2F32CA5AF9100E60096 /* ConnectToManagerVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2632D2F22CA5AF9100E60096 /* ConnectToManagerVM.swift */; }; 2632D2F52CA5AFBB00E60096 /* ConnectSipVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2632D2F42CA5AFBB00E60096 /* ConnectSipVM.swift */; }; + 2637E55F2DD95FE600ACCF91 /* ActiveCallsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2637E55E2DD95FE400ACCF91 /* ActiveCallsHelper.swift */; }; + 2637E5612DD96BE000ACCF91 /* ActiveCallsHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2637E5602DD96BE000ACCF91 /* ActiveCallsHelperTests.swift */; }; + 2637E5632DDE274E00ACCF91 /* CallBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2637E5622DDE274E00ACCF91 /* CallBannerView.swift */; }; + 2637E5652DDE27B600ACCF91 /* CallBannerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2637E5642DDE27B600ACCF91 /* CallBannerViewModel.swift */; }; + 2637E5662DDE662200ACCF91 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265C436C2862561700B4BE73 /* Constants.swift */; }; + 2637E5692DDE662200ACCF91 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265C436C2862561700B4BE73 /* Constants.swift */; }; 263B7158246D9390007044C4 /* SmartListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263B7157246D9390007044C4 /* SmartListCell.swift */; }; 263B715A246D9556007044C4 /* IncognitoSmartListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263B7159246D9556007044C4 /* IncognitoSmartListCell.swift */; }; 263B715C246D96E5007044C4 /* IncognitoSmartListCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 263B715B246D96E5007044C4 /* IncognitoSmartListCell.xib */; }; + 263C16662D94E1BA008E102C /* CallManagementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C16652D94E1B3008E102C /* CallManagementService.swift */; }; + 263C16682D94E24E008E102C /* MediaManagementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C16672D94E24D008E102C /* MediaManagementService.swift */; }; + 263C166A2D94E2AA008E102C /* ConferenceManagementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C16692D94E2A8008E102C /* ConferenceManagementService.swift */; }; + 263C166C2D94E2E4008E102C /* MessageHandlingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C166B2D94E2E0008E102C /* MessageHandlingService.swift */; }; + 263C16752D96F46D008E102C /* MediaManagementServiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C16742D96F469008E102C /* MediaManagementServiceTest.swift */; }; + 263C16782D9702F2008E102C /* ObjCMockCallsAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = 263C16772D9702F2008E102C /* ObjCMockCallsAdapter.m */; }; + 263C167B2D9723A5008E102C /* MessageHandlingServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C167A2D972394008E102C /* MessageHandlingServiceTests.swift */; }; + 263C167D2D98C957008E102C /* CallTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C167C2D98C957008E102C /* CallTestUtils.swift */; }; + 263C167F2D999356008E102C /* ConferenceManagementServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C167E2D999348008E102C /* ConferenceManagementServiceTests.swift */; }; + 263C16832D9DBCE6008E102C /* ThreadSafeQueueHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C16822D9DBCE6008E102C /* ThreadSafeQueueHelper.swift */; }; + 263C16852D9EE3F9008E102C /* CallManagementServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C16842D9EE3F9008E102C /* CallManagementServiceTests.swift */; }; + 263C16872D9EF75E008E102C /* CallsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C16862D9EF75E008E102C /* CallsServiceTests.swift */; }; 263D92DA2C10FC0A00C6EB9D /* TestEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26454F7B2B7E770900CFF06C /* TestEnvironment.swift */; }; 263F61722B4460A400240AEE /* ReactionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263F61712B4460A400240AEE /* ReactionRowView.swift */; }; 263F61742B4460B200240AEE /* ReactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263F61732B4460B200240AEE /* ReactionsView.swift */; }; @@ -828,9 +846,26 @@ 2632D2F22CA5AF9100E60096 /* ConnectToManagerVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToManagerVM.swift; sourceTree = "<group>"; }; 2632D2F42CA5AFBB00E60096 /* ConnectSipVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectSipVM.swift; sourceTree = "<group>"; }; 26376721245315E600CDC51F /* Debug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Debug.entitlements; sourceTree = "<group>"; }; + 2637E55E2DD95FE400ACCF91 /* ActiveCallsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallsHelper.swift; sourceTree = "<group>"; }; + 2637E5602DD96BE000ACCF91 /* ActiveCallsHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallsHelperTests.swift; sourceTree = "<group>"; }; + 2637E5622DDE274E00ACCF91 /* CallBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallBannerView.swift; sourceTree = "<group>"; }; + 2637E5642DDE27B600ACCF91 /* CallBannerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallBannerViewModel.swift; sourceTree = "<group>"; }; 263B7157246D9390007044C4 /* SmartListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartListCell.swift; sourceTree = "<group>"; }; 263B7159246D9556007044C4 /* IncognitoSmartListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoSmartListCell.swift; sourceTree = "<group>"; }; 263B715B246D96E5007044C4 /* IncognitoSmartListCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IncognitoSmartListCell.xib; sourceTree = "<group>"; }; + 263C16652D94E1B3008E102C /* CallManagementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManagementService.swift; sourceTree = "<group>"; }; + 263C16672D94E24D008E102C /* MediaManagementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaManagementService.swift; sourceTree = "<group>"; }; + 263C16692D94E2A8008E102C /* ConferenceManagementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConferenceManagementService.swift; sourceTree = "<group>"; }; + 263C166B2D94E2E0008E102C /* MessageHandlingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageHandlingService.swift; sourceTree = "<group>"; }; + 263C16742D96F469008E102C /* MediaManagementServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaManagementServiceTest.swift; sourceTree = "<group>"; }; + 263C16762D9702F2008E102C /* ObjCMockCallsAdapter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ObjCMockCallsAdapter.h; sourceTree = "<group>"; }; + 263C16772D9702F2008E102C /* ObjCMockCallsAdapter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjCMockCallsAdapter.m; sourceTree = "<group>"; }; + 263C167A2D972394008E102C /* MessageHandlingServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageHandlingServiceTests.swift; sourceTree = "<group>"; }; + 263C167C2D98C957008E102C /* CallTestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTestUtils.swift; sourceTree = "<group>"; }; + 263C167E2D999348008E102C /* ConferenceManagementServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConferenceManagementServiceTests.swift; sourceTree = "<group>"; }; + 263C16822D9DBCE6008E102C /* ThreadSafeQueueHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeQueueHelper.swift; sourceTree = "<group>"; }; + 263C16842D9EE3F9008E102C /* CallManagementServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManagementServiceTests.swift; sourceTree = "<group>"; }; + 263C16862D9EF75E008E102C /* CallsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallsServiceTests.swift; sourceTree = "<group>"; }; 263F61712B4460A400240AEE /* ReactionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionRowView.swift; sourceTree = "<group>"; }; 263F61732B4460B200240AEE /* ReactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsView.swift; sourceTree = "<group>"; }; 263F61752B45BBA100240AEE /* ReactionsContainerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsContainerModel.swift; sourceTree = "<group>"; }; @@ -1183,6 +1218,10 @@ BBD3D750297B4CA00098DB02 /* SwarmInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwarmInfoView.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 26EC6EF32DE0E7B9000BA551 /* ActiveCalls */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ActiveCalls; sourceTree = "<group>"; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 043999F01D1C2D9D00E99CD9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -1520,6 +1559,7 @@ 02E1A0261DDE4C2E00D75B59 /* Services */ = { isa = PBXGroup; children = ( + 263C16642D94E1A6008E102C /* Calls */, 56BBC9A01ED714DF00CDAF8B /* MessagesAdapterDelegate.swift */, 56BBC9A11ED714DF00CDAF8B /* ConversationsService.swift */, 02B22E081DF7585F000358C9 /* DaemonService.swift */, @@ -1536,7 +1576,6 @@ 0EF78DE21FD0AE3000FC6966 /* ConversationsManager.swift */, 0E48F9D21FDF150700D6CC08 /* GeneratedInteractionsManager.swift */, 0E49096B1FEAB225005CAA50 /* CallsAdapterDelegate.swift */, - 0E49096D1FEAC0DE005CAA50 /* CallsService.swift */, 62AA15C21FFC39C80064A063 /* VideoAdapterDelegate.swift */, 62AA15C91FFD3D7E0064A063 /* VideoService.swift */, 62AF685D201A61FF003AA9E8 /* AudioService.swift */, @@ -1805,6 +1844,7 @@ 0E44B62D202B9DC40060F71B /* Helpers */ = { isa = PBXGroup; children = ( + 263C16822D9DBCE6008E102C /* ThreadSafeQueueHelper.swift */, 627F11F020348FBF006560B5 /* AvatarView.swift */, BB6690032A99043200875848 /* SharedActionsPresenter.swift */, 0EE1B54F1F75AD4700BA98EE /* VCardUtils.swift */, @@ -1909,6 +1949,7 @@ 1A2D18A71F290FAA00B2C785 /* Conversations */ = { isa = PBXGroup; children = ( + 26EC6EF32DE0E7B9000BA551 /* ActiveCalls */, 26074FD524F7FF5B00374570 /* Preview */, 0E5806F123BE42C8007D1F5D /* views */, 446FAF172373421500519C4F /* SendFile */, @@ -2073,6 +2114,8 @@ 260AE62A29B8F97300D66D5E /* TestableSwarmInfo.swift */, 260AE63D29C2305800D66D5E /* MockCalls.swift */, 2617A1222C493FB7002CFD4A /* MockAccountsService.swift */, + 263C16762D9702F2008E102C /* ObjCMockCallsAdapter.h */, + 263C16772D9702F2008E102C /* ObjCMockCallsAdapter.m */, ); path = TestableModels; sourceTree = "<group>"; @@ -2089,6 +2132,13 @@ 260AE64129C266B300D66D5E /* CallsTests */ = { isa = PBXGroup; children = ( + 2637E5602DD96BE000ACCF91 /* ActiveCallsHelperTests.swift */, + 263C16862D9EF75E008E102C /* CallsServiceTests.swift */, + 263C16842D9EE3F9008E102C /* CallManagementServiceTests.swift */, + 263C167E2D999348008E102C /* ConferenceManagementServiceTests.swift */, + 263C167C2D98C957008E102C /* CallTestUtils.swift */, + 263C167A2D972394008E102C /* MessageHandlingServiceTests.swift */, + 263C16742D96F469008E102C /* MediaManagementServiceTest.swift */, 2673D62925261DD9000C56CB /* ConferenceMenuItemsManagerTest.swift */, 260AE63929C0C81300D66D5E /* CallProviderDelegateTests.swift */, ); @@ -2141,6 +2191,19 @@ name = AccountsTests; sourceTree = "<group>"; }; + 263C16642D94E1A6008E102C /* Calls */ = { + isa = PBXGroup; + children = ( + 2637E55E2DD95FE400ACCF91 /* ActiveCallsHelper.swift */, + 0E49096D1FEAC0DE005CAA50 /* CallsService.swift */, + 263C166B2D94E2E0008E102C /* MessageHandlingService.swift */, + 263C16692D94E2A8008E102C /* ConferenceManagementService.swift */, + 263C16672D94E24D008E102C /* MediaManagementService.swift */, + 263C16652D94E1B3008E102C /* CallManagementService.swift */, + ); + path = Calls; + sourceTree = "<group>"; + }; 26454FB52B7FDCA100CFF06C /* About */ = { isa = PBXGroup; children = ( @@ -2185,6 +2248,7 @@ 265DFB04292F060800834B97 /* Views */ = { isa = PBXGroup; children = ( + 2637E5622DDE274E00ACCF91 /* CallBannerView.swift */, 26EF35EE29075A5300D97E14 /* MessageStackView.swift */, 269DA09C28E23F57007D51D6 /* MessagesListView.swift */, BB3B0A402971AEE30083CAD8 /* LocationSharingView.swift */, @@ -2207,6 +2271,7 @@ 265DFB05292F061400834B97 /* ViewModels */ = { isa = PBXGroup; children = ( + 2637E5642DDE27B600ACCF91 /* CallBannerViewModel.swift */, 260C73F029196B66005C513F /* MessageReplyTargetVM.swift */, 269DA09E28E244F1007D51D6 /* MessagesListVM.swift */, 26EF35EA28E38DA200D97E14 /* MessageContentVM.swift */, @@ -2550,6 +2615,9 @@ dependencies = ( 26A88C0A266FFFC800888EED /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 26EC6EF32DE0E7B9000BA551 /* ActiveCalls */, + ); name = Ring; packageProductDependencies = ( 5593D7D22B7FE00A00DA109C /* MCEmojiPicker */, @@ -2857,6 +2925,7 @@ 557086521E8ADB9D001A7CE4 /* SystemAdapter.mm in Sources */, 263B7158246D9390007044C4 /* SmartListCell.swift in Sources */, 0586C94B1F684DF600613517 /* UIImage+Helpers.swift in Sources */, + 263C166A2D94E2AA008E102C /* ConferenceManagementService.swift in Sources */, 4430A671236CBC7A00747177 /* ContactPickerViewModel.swift in Sources */, 621231F91F880EDF009B86F0 /* UILabel+Ring.swift in Sources */, 26A34EDA2C6EB7C800A41DD4 /* BackupAccount.swift in Sources */, @@ -2868,6 +2937,7 @@ BB4C6E2629229131001C901A /* ColorExtension.swift in Sources */, 269DA09928E23D37007D51D6 /* MessageRowView.swift in Sources */, 648AF76D24ED7CA90004D727 /* UITextView+Helpers.swift in Sources */, + 2637E5662DDE662200ACCF91 /* Constants.swift in Sources */, 26069B6724C9FCE8002361A3 /* ObjCHandler.m in Sources */, 269DA09D28E23F57007D51D6 /* MessagesListView.swift in Sources */, 446FAF1B2373425E00519C4F /* SendFileViewController.swift in Sources */, @@ -2887,7 +2957,9 @@ 26A5CE3B26BD00E700E147EA /* Array+Helper.swift in Sources */, 26600A992BEA855900EFCFD1 /* ThreadSafeArray.swift in Sources */, 1DF75ABE296E07890055EA87 /* Text+Helpers.swift in Sources */, + 263C16682D94E24E008E102C /* MediaManagementService.swift in Sources */, 26A34ED82C6EAE9A00A41DD4 /* DocumentPicker.swift in Sources */, + 2637E55F2DD95FE600ACCF91 /* ActiveCallsHelper.swift in Sources */, BB06BD8529D4C1210064F0FC /* DurationPicker.swift in Sources */, 268C87F62C1CC78100593D7C /* LinkedDevicesVM.swift in Sources */, 0ECB4E2822B2D4840097CD7B /* CallsProviderService.swift in Sources */, @@ -2989,6 +3061,7 @@ 260717702CAD901D00494875 /* ConversationDataSource.swift in Sources */, BB06BD8329D492AA0064F0FC /* LocationSharingVM.swift in Sources */, 2662FC79246B1E1700FA7782 /* JamiSearchView.swift in Sources */, + 2637E5632DDE274E00ACCF91 /* CallBannerView.swift in Sources */, 0E44B62F202B9DE40060F71B /* LocalNotificationsHelper.swift in Sources */, 2673D630252657B0000C56CB /* ConferenceLayout.swift in Sources */, 1A2D18FC1F292DAD00B2C785 /* ConversationCell.swift in Sources */, @@ -3002,9 +3075,11 @@ 1A5DC0371F35675E0075E8EF /* ContactRequestCell.swift in Sources */, 1A20417C1F1E56FF00C08435 /* WelcomeVM.swift in Sources */, 265DFB09292FD25000834B97 /* DefaultTransferView.swift in Sources */, + 263C16832D9DBCE6008E102C /* ThreadSafeQueueHelper.swift in Sources */, 1A5DC03D1F35678D0075E8EF /* RequestItem.swift in Sources */, 2659F65827483656009107F1 /* VideoManager.swift in Sources */, 262581502B4EF15D00314C42 /* MessagePanelVM.swift in Sources */, + 263C16662D94E1BA008E102C /* CallManagementService.swift in Sources */, BB3AB06C29316FA6006906BA /* ViewDidLoadModifier.swift in Sources */, 1A0C4EE31F1D673600550433 /* InjectionBag.swift in Sources */, 0E3BD4262044778100A50DDF /* ContactViewModel.swift in Sources */, @@ -3013,6 +3088,7 @@ 564C44641E943E1E000F92B1 /* NameRegistrationAdapterDelegate.swift in Sources */, 1A2D18AA1F29131900B2C785 /* ConversationsCoordinator.swift in Sources */, 62AF685E201A61FF003AA9E8 /* AudioService.swift in Sources */, + 2637E5652DDE27B600ACCF91 /* CallBannerViewModel.swift in Sources */, 043999F71D1C2D9D00E99CD9 /* AppDelegate.swift in Sources */, 265DFB1129302A8700834B97 /* ContactMessageView.swift in Sources */, 265DFB03292EB94B00834B97 /* MessageContainerModel.swift in Sources */, @@ -3094,6 +3170,7 @@ 265DFB07292FBC4200834B97 /* TransferHelper.swift in Sources */, 26B37339263C439B00E2EE28 /* CustomSearchController.swift in Sources */, 56BBC9BC1ED7161200CDAF8B /* Date+Helpers.swift in Sources */, + 263C166C2D94E2E4008E102C /* MessageHandlingService.swift in Sources */, 564C44621E943DE6000F92B1 /* NameService.swift in Sources */, 0EF78DE31FD0AE3000FC6966 /* ConversationsManager.swift in Sources */, 64DBCD2224DB3CF600CB5CA2 /* UserSearchResponse.m in Sources */, @@ -3155,25 +3232,33 @@ buildActionMask = 2147483647; files = ( 260AE63329BA5DF600D66D5E /* SwarmInfoTests.swift in Sources */, + 263C167B2D9723A5008E102C /* MessageHandlingServiceTests.swift in Sources */, + 263C16852D9EE3F9008E102C /* CallManagementServiceTests.swift in Sources */, 2617A1212C493BC5002CFD4A /* AccountsServiceTest.swift in Sources */, BB8BBCA329F04DA1007228BA /* LocationSharingServiceTests.swift in Sources */, 260AE64529C8FD2500D66D5E /* VCardUtilsTests.swift in Sources */, 2673D62A25261DD9000C56CB /* ConferenceMenuItemsManagerTest.swift in Sources */, 024B61311DF7656A00C4F9DE /* FixtureFailInitDRingAdapter.mm in Sources */, 260AE62F29BA3B6900D66D5E /* ConversationModelTests.swift in Sources */, + 2637E5612DD96BE000ACCF91 /* ActiveCallsHelperTests.swift in Sources */, 260AE63729BE177000D66D5E /* ContactUtilsTests.swift in Sources */, 260AE63A29C0C81300D66D5E /* CallProviderDelegateTests.swift in Sources */, 260AE62B29B8F97300D66D5E /* TestableSwarmInfo.swift in Sources */, + 263C167D2D98C957008E102C /* CallTestUtils.swift in Sources */, 5557FD4A1E81AE850043E394 /* AccountModelHelperTests.swift in Sources */, 04399A111D1C2D9D00E99CD9 /* RingTests.swift in Sources */, 260AE63529BE00F100D66D5E /* Constants.swift in Sources */, 029CE9D71E1D8C860000C8E1 /* ServiceEventTests.swift in Sources */, + 263C16872D9EF75E008E102C /* CallsServiceTests.swift in Sources */, 260AE62929B8ECA400D66D5E /* TestableFilteredDataSource.swift in Sources */, 260AE62729B8E89300D66D5E /* JamiSearchViewModelTests.swift in Sources */, 024B612C1DF7654F00C4F9DE /* DaemonServiceTests.swift in Sources */, + 263C167F2D999356008E102C /* ConferenceManagementServiceTests.swift in Sources */, + 263C16752D96F46D008E102C /* MediaManagementServiceTest.swift in Sources */, 024B61321DF7656A00C4F9DE /* FixtureFailStartDRingAdapter.mm in Sources */, 260AE63E29C2305800D66D5E /* MockCalls.swift in Sources */, 2617A1232C493FB7002CFD4A /* MockAccountsService.swift in Sources */, + 263C16782D9702F2008E102C /* ObjCMockCallsAdapter.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3198,6 +3283,7 @@ buildActionMask = 2147483647; files = ( 265C436B286254C900B4BE73 /* Constants.swift in Sources */, + 2637E5692DDE662200ACCF91 /* Constants.swift in Sources */, 26BB438B2670191E00019CF6 /* AdapterDelegate.swift in Sources */, 26BB4389267017D400019CF6 /* Utils.mm in Sources */, 26A88C07266FFFC800888EED /* NotificationService.swift in Sources */, @@ -3496,7 +3582,7 @@ LIBRARY_SEARCH_PATHS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.savoirfairelinux.ringTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "RingTests/RingTests-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/RingTests/RingTests-Bridging-Header.h"; SWIFT_SWIFT3_OBJC_INFERENCE = On; SWIFT_VERSION = 4.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ring.app/Ring"; @@ -3521,7 +3607,7 @@ LIBRARY_SEARCH_PATHS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.savoirfairelinux.ringTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "RingTests/RingTests-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/RingTests/RingTests-Bridging-Header.h"; SWIFT_SWIFT3_OBJC_INFERENCE = On; SWIFT_VERSION = 4.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ring.app/Ring"; @@ -3689,7 +3775,7 @@ LIBRARY_SEARCH_PATHS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.savoirfairelinux.ringTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "RingTests/RingTests-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/RingTests/RingTests-Bridging-Header.h"; SWIFT_SWIFT3_OBJC_INFERENCE = On; SWIFT_VERSION = 4.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ring.app/Ring"; @@ -4030,7 +4116,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.savoirfairelinux.ringTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "RingTests/RingTests-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/RingTests/RingTests-Bridging-Header.h"; SWIFT_SWIFT3_OBJC_INFERENCE = On; SWIFT_VERSION = 4.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ring.app/Ring"; diff --git a/Ring/Ring/Bridging/CallsAdapter.mm b/Ring/Ring/Bridging/CallsAdapter.mm index 172c667e0797fa3f03be2b5d81ea35a4e2e8c431..f8a9d1e70102b6e971b96f4dd4a4fdf0cc8ff50b 100644 --- a/Ring/Ring/Bridging/CallsAdapter.mm +++ b/Ring/Ring/Bridging/CallsAdapter.mm @@ -95,7 +95,7 @@ static id <CallsAdapterDelegate> _delegate; withMedia:mediaList]; } })); - + callHandlers.insert(exportable_callback<CallSignal::MediaNegotiationStatus>([&](const std::string& callId, const std::string& event, const std::vector<std::map<std::string, std::string>>& media) { @@ -108,7 +108,7 @@ static id <CallsAdapterDelegate> _delegate; withMedia:mediaList]; } })); - + callHandlers.insert(exportable_callback<CallSignal::MediaChangeRequested>([&](const std::string& accountId, const std::string& callId, const std::vector<std::map<std::string, std::string>>& media) { @@ -154,7 +154,9 @@ static id <CallsAdapterDelegate> _delegate; callHandlers.insert(exportable_callback<CallSignal::ConferenceCreated>([&](const std::string& accountId, const std::string& conversationId, const std::string& confId) { if (CallsAdapter.delegate) { NSString* confIdString = [NSString stringWithUTF8String:confId.c_str()]; - [CallsAdapter.delegate conferenceCreatedWithConference: confIdString accountId:[NSString stringWithUTF8String:accountId.c_str()] ]; + NSString* conversationIdString = [NSString stringWithUTF8String:conversationId.c_str()]; + NSString* accountIdString = [NSString stringWithUTF8String:accountId.c_str()]; + [CallsAdapter.delegate conferenceCreatedWithConferenceId:confIdString conversationId:conversationIdString accountId:accountIdString]; } })); diff --git a/Ring/Ring/Bridging/ConversationsAdapter.mm b/Ring/Ring/Bridging/ConversationsAdapter.mm index 510de991217af1c9a8f9f07324eb592630ae717e..254aa566063d1c6499bf7a43a3de78135fc8d272 100644 --- a/Ring/Ring/Bridging/ConversationsAdapter.mm +++ b/Ring/Ring/Bridging/ConversationsAdapter.mm @@ -86,6 +86,16 @@ static id <MessagesAdapterDelegate> _messagesDelegate; } })); + + confHandlers.insert(exportable_callback<ConfigurationSignal::ActiveCallsChanged>([&](const std::string& account_id, const std::string& conversation_id, const std::vector<std::map<std::string, std::string>>& activeCalls) { + if (ConversationsAdapter.messagesDelegate) { + NSString* accountId = [NSString stringWithUTF8String:account_id.c_str()]; + NSString* conversationId = [NSString stringWithUTF8String:conversation_id.c_str()]; + NSArray* callsDictionary = [Utils vectorOfMapsToArray: activeCalls]; + [ConversationsAdapter.messagesDelegate activeCallsChangedWithConversationId:conversationId accountId:accountId calls:callsDictionary]; + } + })); + confHandlers.insert(exportable_callback<ConfigurationSignal::ComposingStatusChanged>([&](const std::string& account_id, const std::string& convId, const std::string& from, int status) { if (ConversationsAdapter.messagesDelegate) { NSString* fromPeer = [NSString stringWithUTF8String:from.c_str()]; diff --git a/Ring/Ring/Bridging/DRingAdapter.mm b/Ring/Ring/Bridging/DRingAdapter.mm index 59a7dd75fa36352e3e182e5a6f1f3289ea5ebd45..da7288397d5e323dc8c23a88b9dfe558f847d161 100644 --- a/Ring/Ring/Bridging/DRingAdapter.mm +++ b/Ring/Ring/Bridging/DRingAdapter.mm @@ -45,7 +45,7 @@ using namespace libjami; - (BOOL) initDaemonInternal { #if DEBUG - int flag = 0; + int flag = LIBJAMI_FLAG_DEBUG | LIBJAMI_FLAG_CONSOLE_LOG | LIBJAMI_FLAG_SYSLOG; #else int flag = 0; #endif diff --git a/Ring/Ring/Calls/CallViewController.swift b/Ring/Ring/Calls/CallViewController.swift index 6ce28804364879da62bcae714a8e26497b1d7cb5..65ceea1ec6faac2ee77515ad6a14c077e80aec51 100644 --- a/Ring/Ring/Calls/CallViewController.swift +++ b/Ring/Ring/Calls/CallViewController.swift @@ -47,7 +47,10 @@ class CallViewController: UIViewController, StoryboardBased, ViewModelBased, Con if self.viewModel.call != nil && !self.viewConfigured { self.configureIncomingVideoView() } + self.view.backgroundColor = .black self.setupBindings() + self.title = "" + self.navigationItem.hidesBackButton = true UIApplication.shared.isIdleTimerDisabled = true } @@ -60,8 +63,8 @@ class CallViewController: UIViewController, StoryboardBased, ViewModelBased, Con callId: viewModel.callId()) videoContainerViewModel = createContainerViewModel(with: properties) - if let jamiId = viewModel.getJamiId() { - updateParticipant(jamiId: jamiId) + if let uri = viewModel.callURI(), !uri.isEmpty { + updateParticipant(uri: uri) } subscribeCallActions() @@ -78,9 +81,12 @@ class CallViewController: UIViewController, StoryboardBased, ViewModelBased, Con callId: properties.callId) } - private func updateParticipant(jamiId: String) { + private func updateParticipant(uri: String) { let participant = ConferenceParticipant(sinkId: viewModel.conferenceId, isActive: true) - participant.uri = jamiId + participant.uri = uri + if let call = viewModel.call { + participant.isVideoMuted = call.isAudioOnly + } videoContainerViewModel.updateWith(participantsInfo: [participant], mode: .resizeAspect) } diff --git a/Ring/Ring/Calls/CallViewModel.swift b/Ring/Ring/Calls/CallViewModel.swift index 8688fc658df2fd119d81e2201f5f2fcde238b815..f03446955edf9078ca579c827b0a39765ca4ff40 100644 --- a/Ring/Ring/Calls/CallViewModel.swift +++ b/Ring/Ring/Calls/CallViewModel.swift @@ -67,9 +67,9 @@ class CallViewModel: Stateable, ViewModel { var callStarted: BehaviorRelay<Bool> = BehaviorRelay(value: false) var callCompleted = false - func getJamiId() -> String? { + func callURI() -> String? { guard let call = call else { return nil } - return call.participantUri + return call.callUri } var call: CallModel? { @@ -100,7 +100,7 @@ class CallViewModel: Stateable, ViewModel { // data for ViewController binding lazy var showRecordImage: Observable<Bool> = { return self.callService - .currentCallsEvents + .callUpdates .asObservable() .map({[weak self] call in guard let self = self else { return false } @@ -112,10 +112,10 @@ class CallViewModel: Stateable, ViewModel { lazy var dismisVC: Observable<Bool> = { return currentCall .filter({ call in - return !call.isExists() + return !call.isExists() || call.state.isFinished() }) .map({ [weak self] call in - let hide = !call.isExists() + let hide = !call.isExists() || call.state.isFinished() // if it was conference call switch to another running call if hide && call.participantsCallId.count > 1 { // switch to another call @@ -298,31 +298,49 @@ class CallViewModel: Stateable, ViewModel { extension CallViewModel { func cancelCall() { + guard let call = call else { return } self.callService - .hangUpCallOrConference(callId: self.conferenceId) - .subscribe() - .disposed(by: self.disposeBag) + .hangUpCallOrConference(callId: self.conferenceId, isSwarm: isCallForSwarm(), callURI: call.callUri) + } + + func isCallForSwarm() -> Bool { + guard let call = call, + !call.conversationId.isEmpty, + let conversation = conversationService.getConversationForId(conversationId: call.conversationId, accountId: call.accountId) else { + return false + } + return conversation.getParticipants().count > 1 } func answerCall() -> Completable { - return self.callService.accept(call: call) + return self.callService.accept(callId: call?.callId ?? "") } func placeCall(with uri: String, userName: String, account: AccountModel, isAudioOnly: Bool = false) { - self.callService.placeCall(withAccount: account, - toParticipantId: uri, - userName: userName, - videoSource: self.videoService.getVideoSource(), - isAudioOnly: isAudioOnly) + let isSwarm = uri.starts(with: "swarm:") + let callObservable = isSwarm ? + self.callService.placeSwarmCall(withAccount: account, + uri: uri, + userName: userName, + videoSource: self.videoService.getVideoSource(), + isAudioOnly: isAudioOnly) : + self.callService.placeCall(withAccount: account, + toParticipantId: uri, + userName: userName, + videoSource: self.videoService.getVideoSource(), + isAudioOnly: isAudioOnly) + + callObservable + .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) + .observe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) .subscribe(onSuccess: { [weak self] callModel in self?.call = callModel - if self?.isBoothMode() ?? false { - return + if isSwarm { + self?.conferenceId = callModel.callId } - self?.callsProvider - .startCall(account: account, call: callModel) + self?.callsProvider.startCall(account: account, call: callModel) self?.callStarted.accept(true) - }, onFailure: { [weak self] _ in + }, onFailure: { [weak self] _ in self?.callFailed.accept(true) }) .disposed(by: self.disposeBag) @@ -343,8 +361,6 @@ extension CallViewModel { userName: contactToAdd.registeredName, videSource: self.videoService.getVideoSource(), isAudioOnly: call.isAudioOnly) - .subscribe() - .disposed(by: self.disposeBag) return } guard let secondCall = self.callService.call(callID: contact.conferenceID) else { return } @@ -357,17 +373,27 @@ extension CallViewModel { } func showConversations() { - guard let call = self.call else { - return + guard let call = self.call else { return } + + if let conversation = findConversation(for: call) { + self.stateSubject.onNext(ConversationState.openConversationFromCall(conversation: conversation)) } - guard let jamiId = JamiURI(schema: URIType.ring, infoHash: call.participantUri).hash else { - return + } + + private func findConversation(for call: CallModel) -> ConversationModel? { + if !call.conversationId.isEmpty { + return conversationService.getConversationForId(conversationId: call.conversationId, accountId: call.accountId) } - guard let conversation = self.conversationService.getConversationForParticipant(jamiId: jamiId, accountId: call.accountId) else { - return + if let activeCall = call.getactiveCallFromURI() { + return conversationService.getConversationForId(conversationId: activeCall.conversationId, accountId: call.accountId) } - self.stateSubject.onNext(ConversationState.openConversationFromCall(conversation: conversation)) + + if let jamiId = JamiURI(schema: URIType.ring, infoHash: call.callUri).hash { + return conversationService.getConversationForParticipant(jamiId: jamiId, accountId: call.accountId) + } + + return nil } func togglePauseCall() { @@ -398,7 +424,9 @@ extension CallViewModel { let callId = (self.isHost ?? false) ? self.conferenceId : call.callId guard let callToMute = self.callService.call(callID: callId) else { return } let device = self.videoService.getCurrentVideoSource() - self.callService.updateCallMediaIfNeeded(call: callToMute) + Task { + await self.callService.updateCallMediaIfNeeded(call: callToMute) + } self.videoService.requestMediaChange(call: callToMute, mediaLabel: "audio_0", source: device) updateCallStateForConferenceHost() } @@ -408,7 +436,9 @@ extension CallViewModel { let callId = (self.isHost ?? false) ? self.conferenceId : call.callId guard let callToMute = self.callService.call(callID: callId) else { return } let device = self.videoService.getCurrentVideoSource() - self.callService.updateCallMediaIfNeeded(call: callToMute) + Task { + await self.callService.updateCallMediaIfNeeded(call: callToMute) + } self.videoService.requestMediaChange(call: callToMute, mediaLabel: "video_0", source: device) updateCallStateForConferenceHost() } diff --git a/Ring/Ring/Calls/Conference/ContactPickerViewModel.swift b/Ring/Ring/Calls/Conference/ContactPickerViewModel.swift index f16261b2d1315faac0f931b6c46a6e0601a17701..58a36cf37ef2536231482fe882e4a8d80be989a5 100644 --- a/Ring/Ring/Calls/Conference/ContactPickerViewModel.swift +++ b/Ring/Ring/Calls/Conference/ContactPickerViewModel.swift @@ -45,7 +45,7 @@ class ContactPickerViewModel: ViewModel { } return Observable .combineLatest(self.contactsService.contacts.asObservable(), - self.callService.calls.asObservable()) {[weak self] (contacts, calls) -> [ContactPickerSection] in + self.callService.calls.observable) {[weak self] (contacts, calls) -> [ContactPickerSection] in var sections = [ContactPickerSection]() guard let self = self else { return sections } guard let currentCall = self.callService.call(callID: self.currentCallId) else { return sections } @@ -213,7 +213,7 @@ extension ContactPickerViewModel { calls.values.forEach { call in guard let account = self.accountService.getAccount(fromAccountId: call.accountId) else { return } let type = account.type == AccountType.ring ? URIType.ring : URIType.sip - let uri = JamiURI.init(schema: type, infoHash: call.participantUri, account: account) + let uri = JamiURI.init(schema: type, infoHash: call.callUri, account: account) guard let uriString = uri.uriString else { return } guard let hashString = uri.hash else { return } callURIs.append(uriString) diff --git a/Ring/Ring/Calls/Conference/SwiftUI/Models/ContainerViewModel.swift b/Ring/Ring/Calls/Conference/SwiftUI/Models/ContainerViewModel.swift index aa4d13e4af162f99b4bec85c1160b4378d012ddb..318c74fa9f915ebc8e9d862404ac6f884fc6bf44 100644 --- a/Ring/Ring/Calls/Conference/SwiftUI/Models/ContainerViewModel.swift +++ b/Ring/Ring/Calls/Conference/SwiftUI/Models/ContainerViewModel.swift @@ -30,6 +30,7 @@ class ContainerViewModel: ObservableObject { @Published var localImage = UIImage() @Published var callAnswered = false @Published var callState = "" + @Published var isSwarmCall: Bool = false private var conferenceActionsModel: ConferenceActionsModel var mainGridViewModel: MainGridViewModel = MainGridViewModel() @@ -81,8 +82,15 @@ class ContainerViewModel: ObservableObject { self.callService = injectionBag.callService self.currentCall = currentCall self.callAnswered = incoming - if let call = self.callService.call(callID: callId), call.state == .current || call.state == .hold { - self.callAnswered = true + + if let call = self.callService.call(callID: callId) { + if call.state == .current || call.state == .hold || !call.conversationId.isEmpty { + self.callAnswered = true + } + + if !call.conversationId.isEmpty { + self.isSwarmCall = true + } } self.actionsViewModel = ActionsViewModel(actionsState: self.actionsStateSubject, currentCall: currentCall, audioService: injectionBag.audioService) @@ -124,12 +132,18 @@ class ContainerViewModel: ObservableObject { self.callService.currentConferenceEvent .observe(on: MainScheduler.instance) .asObservable() - .filter(isRelevantConference) - .subscribe(onNext: handleConferenceEvent) + .filter { [weak self] conference in + guard let self = self else { return false } + return self.isRelevantConference(conference) + } + .subscribe(onNext: { [weak self] conference in + guard let self = self else { return } + self.handleConferenceEvent(conference) + }) .disposed(by: disposeBag) self.callService - .inConferenceCalls + .inConferenceCalls() .asObservable() .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] call in @@ -206,7 +220,7 @@ class ContainerViewModel: ObservableObject { private func updateViewForCurrentCall(_ call: CallModel) { let participant = ConferenceParticipant(sinkId: call.callId, isActive: true) - participant.uri = call.participantUri + participant.uri = call.callUri updateWith(participantsInfo: [participant], mode: .resizeAspect) hasIncomingVideo = true } @@ -245,9 +259,14 @@ class ContainerViewModel: ObservableObject { self.participants.append(participant) if self.participants.count == 1 { participant.videoRunning - .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] hasVideo in - self?.hasIncomingVideo = hasVideo + guard let self = self else { return } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if self.participants.count == 1 { + self.hasIncomingVideo = hasVideo + } + } }) .disposed(by: self.videoRunningBag) } else { diff --git a/Ring/Ring/Calls/Conference/SwiftUI/Models/ParticipantViewModel.swift b/Ring/Ring/Calls/Conference/SwiftUI/Models/ParticipantViewModel.swift index 205331039e5ec7c3a9b0627c96430cb854c9686c..9ab40225f4d29c68dfc41a5f6dac9ba465f888c2 100644 --- a/Ring/Ring/Calls/Conference/SwiftUI/Models/ParticipantViewModel.swift +++ b/Ring/Ring/Calls/Conference/SwiftUI/Models/ParticipantViewModel.swift @@ -149,6 +149,10 @@ class ParticipantViewModel: Identifiable, ObservableObject, Equatable, Hashable }) .disposed(by: disposeBag) self.subscribe() + if info.isVideoMuted != isVideoMuted { + isVideoMuted = info.isVideoMuted + } + } func radians(from degrees: Int) -> CGFloat { @@ -180,13 +184,24 @@ class ParticipantViewModel: Identifiable, ObservableObject, Equatable, Hashable var videoRunning = BehaviorRelay<Bool>(value: false) func subscribe() { + self.videoService.addListener(withsinkId: self.id) + + // For swarm calls, sinkId might be in a different format, try to extract the base ID + // in case we need to check alternative IDs + let baseSinkId = self.id.components(separatedBy: "_").first ?? self.id + if !subscribed { subscribed = true + self.videoService.videoInputManager.frameSubject - .filter({ [weak self] result in - guard let self = self else { return false } - return result.sinkId == self.id + .filter({ result in + let baseIdMatch = result.sinkId.components(separatedBy: "_").first == baseSinkId + + if baseIdMatch { + return true + } + return false }) .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] info in diff --git a/Ring/Ring/Calls/Conference/SwiftUI/Models/PendingConferenceCall.swift b/Ring/Ring/Calls/Conference/SwiftUI/Models/PendingConferenceCall.swift index c2330539f8aa7912d42840d3b04b1a50c04398bc..ae1d710137f45a831e727b34b85361988e2c58d8 100644 --- a/Ring/Ring/Calls/Conference/SwiftUI/Models/PendingConferenceCall.swift +++ b/Ring/Ring/Calls/Conference/SwiftUI/Models/PendingConferenceCall.swift @@ -60,6 +60,7 @@ class PendingConferenceCall { } func stopPendingCall() { - self.callsService.stopPendingCall(callId: self.id) + guard let call = self.callsService.call(callID: self.id) else { return } + self.callsService.stopCall(call: call) } } diff --git a/Ring/Ring/Calls/Conference/SwiftUI/Views/ContainerView.swift b/Ring/Ring/Calls/Conference/SwiftUI/Views/ContainerView.swift index 58cc34597825e073497900fac97cd45d21a3b47d..c7a7e92965a10535cf004e6a1f2f36a67ed0881a 100644 --- a/Ring/Ring/Calls/Conference/SwiftUI/Views/ContainerView.swift +++ b/Ring/Ring/Calls/Conference/SwiftUI/Views/ContainerView.swift @@ -79,8 +79,6 @@ struct ContainerView: View { @SwiftUI.State var showTopGridView = true @SwiftUI.State private var maxHeight = maxButtonsWidgetHeight @SwiftUI.State var buttonsVisible: Bool = true - @SwiftUI.State var hasLocalVideo: Bool = false - @SwiftUI.State var hasIncomingVideo: Bool = false @SwiftUI.State var showInitialView: Bool = true @SwiftUI.State var audioCallViewIdentifier = "audioCallView_" @Namespace var namespace @@ -115,14 +113,15 @@ struct ContainerView: View { } } .padding(5) - if !hasIncomingVideo && !showInitialView { + + if !model.hasIncomingVideo && !showInitialView { audioCallView() } - if hasLocalVideo { + if model.hasLocalVideo { if showInitialView { initialVideoCallView() - } else { + } else if !model.isSwarmCall { DragableCaptureView(image: $model.localImage, namespace: namespace) } } else if showInitialView { @@ -139,15 +138,14 @@ struct ContainerView: View { buttonsVisible.toggle() } } - .onChange(of: model.hasLocalVideo) { newValue in - hasLocalVideo = newValue - } - .onChange(of: model.hasIncomingVideo) { newValue in - hasIncomingVideo = newValue + .onAppear {[weak model] in + guard let model = model else { return } + showInitialView = !model.callAnswered } - .onChange(of: model.callAnswered) { newValue in + .onChange(of: model.callAnswered) {[weak model] newValue in + guard let model = model else { return } if newValue { - if hasLocalVideo { + if model.hasLocalVideo { withAnimation(.dragableCaptureViewAnimation()) { showInitialView = false } @@ -156,13 +154,6 @@ struct ContainerView: View { } } } - .onAppear { - hasLocalVideo = model.hasLocalVideo - hasIncomingVideo = model.hasIncomingVideo - if model.callAnswered { - showInitialView = false - } - } .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in let orientation = UIDevice.current.orientation.isLandscape ? "landscape" : "portrait" self.audioCallViewIdentifier = "audioCallView_" + orientation diff --git a/Ring/Ring/Constants/Generated/Strings.swift b/Ring/Ring/Constants/Generated/Strings.swift index 8144533ec6448f027d13a63d4d490cfee020c9d8..738c805add21b12133a77b83461abed1b23b2a03 100644 --- a/Ring/Ring/Constants/Generated/Strings.swift +++ b/Ring/Ring/Constants/Generated/Strings.swift @@ -587,6 +587,10 @@ internal enum L10n { internal static let noBlockedContacts = L10n.tr("Localizable", "blockListPage.noBlockedContacts", fallback: "No blocked contacts") } internal enum Calls { + /// A call is in progress. Do you want to join the call? + internal static let activeCallInConversation = L10n.tr("Localizable", "calls.activeCallInConversation", fallback: "A call is in progress. Do you want to join the call?") + /// A call is in progress. Do you want to join the call? You can also join the call later from the conversation. + internal static let activeCallLabel = L10n.tr("Localizable", "calls.activeCallLabel", fallback: "A call is in progress. Do you want to join the call? You can also join the call later from the conversation.") /// Call finished internal static let callFinished = L10n.tr("Localizable", "calls.callFinished", fallback: "Call finished") /// Connecting… diff --git a/Ring/Ring/Coordinators/AppCoordinator.swift b/Ring/Ring/Coordinators/AppCoordinator.swift index 1af4c7198248e9067ca934ecd9dce8beb1bc00e7..440c545ce7e0d2dbb05e978cd6464de2921ed1d9 100644 --- a/Ring/Ring/Coordinators/AppCoordinator.swift +++ b/Ring/Ring/Coordinators/AppCoordinator.swift @@ -41,6 +41,7 @@ public enum AppState: State { public enum VCType: String { case conversation case contact + case activeCalls case blockList case log } diff --git a/Ring/Ring/Features/Conversations/ActiveCalls/ActiveCallsView.swift b/Ring/Ring/Features/Conversations/ActiveCalls/ActiveCallsView.swift new file mode 100644 index 0000000000000000000000000000000000000000..e6fe113814f2138779bbba982d0dd63e17c1c2a3 --- /dev/null +++ b/Ring/Ring/Features/Conversations/ActiveCalls/ActiveCallsView.swift @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import SwiftUI +import RxSwift + +struct ActiveCallsView: View { + @ObservedObject var viewModel: ActiveCallsViewModel + @Environment(\.presentationMode) + var presentationMode + @SwiftUI.State private var isContentVisible = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + if !(Array(viewModel.callsByAccount.keys).isEmpty) { + ForEach(Array(viewModel.callsByAccount.keys), id: \.self) { accountId in + if let calls = viewModel.callsByAccount[accountId] { + VStack(alignment: .leading, spacing: 8) { + ForEach(calls, id: \.call.id) { callViewModel in + CallRowView(viewModel: callViewModel) + .transition(.move(edge: .top)) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical) + .background( + ZStack { + Color(UIColor.systemBackground) + VisualEffect(style: .systemChromeMaterial, withVibrancy: true) + Color(UIColor.systemGroupedBackground) + } + ) + .cornerRadius(10) + .shadow(radius: 5) + .padding(.horizontal) + .padding(.bottom) + .offset(y: -40) + .scaleEffect(isContentVisible ? 1 : 0.8) + } + Spacer() + } + .ignoresSafeArea() + .background(Color.black.opacity(isContentVisible ? 0.5 : 0)) + .navigationBarTitleDisplayMode(.inline) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isContentVisible) + .ignoresSafeArea() + .onAppear { + isContentVisible = true + } + .onChange(of: viewModel.callsByAccount) { accounts in + if accounts.isEmpty || accounts.allSatisfy({ $0.value.isEmpty }) { + presentationMode.wrappedValue.dismiss() + } + } + } +} + +struct CallRowView: View { + @ObservedObject var viewModel: ActiveCallRowViewModel + + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 12) { + if let avatar = viewModel.avatar { + Image(uiImage: avatar) + .resizable() + .scaledToFill() + .frame(width: 50, height: 50) + .clipShape(Circle()) + } else { + Circle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 50, height: 50) + } + + VStack(alignment: .leading, spacing: 10) { + Text(viewModel.title) + .font(.headline) + Text(L10n.Calls.activeCallLabel) + } + } + .padding(.horizontal) + + HStack { + Spacer() + Button(action: { + viewModel.acceptCall() + }, label: { + Image(systemName: "video") + .font(.system(size: 25)) + .foregroundColor(.jamiColor) + .padding(10) + }) + Spacer() + Button(action: { + viewModel.acceptAudioCall() + }, label: { + Image(systemName: "phone") + .font(.system(size: 25)) + .foregroundColor(.jamiColor) + .padding(10) + }) + Spacer() + + Button(action: { + viewModel.rejectCall() + }) { + Image(systemName: "xmark") + .font(.system(size: 25)) + .foregroundColor(.jamiColor) + .padding(10) + } + Spacer() + } + } + .frame(maxWidth: .infinity) + } +} diff --git a/Ring/Ring/Features/Conversations/ActiveCalls/ActiveCallsViewModel.swift b/Ring/Ring/Features/Conversations/ActiveCalls/ActiveCallsViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..b9e67f2ccd719d0ccf19e5b4525aae42798dbf4b --- /dev/null +++ b/Ring/Ring/Features/Conversations/ActiveCalls/ActiveCallsViewModel.swift @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import RxSwift + +class ActiveCallsViewModel: ObservableObject, Stateable { + @Published var callsByAccount: [String: [ActiveCallRowViewModel]] = [:] + + private let callService: CallsService + private let accountsService: AccountsService + private let conversationsSource: ConversationDataSource + private let disposeBag = DisposeBag() + + private let stateSubject = PublishSubject<State>() + lazy var state: Observable<State> = { + return self.stateSubject.asObservable() + }() + + init(injectionBag: InjectionBag, conversationsSource: ConversationDataSource) { + self.callService = injectionBag.callService + self.accountsService = injectionBag.accountService + self.conversationsSource = conversationsSource + self.observeActiveCalls() + } + + private func observeActiveCalls() { + callService.activeCalls + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] trackersByAccount in + self?.updateCallViewModels(from: trackersByAccount) + }) + .disposed(by: disposeBag) + } + + private func updateCallViewModels(from trackersByAccount: [String: AccountCallTracker]) { + for (accountId, tracker) in trackersByAccount { + let viewModels: [ActiveCallRowViewModel] = tracker.incomingUnansweredNotIgnoredCalls() + .compactMap { call in + guard let conversation = findConversation(for: call), + let swarmInfo = conversation.swarmInfo else { return nil } + return ActiveCallRowViewModel( + call: call, + stateSubject: stateSubject, + callService: callService, + swarmInfo: swarmInfo + ) + } + callsByAccount[accountId] = viewModels + } + } + + private func findConversation(for call: ActiveCall) -> ConversationViewModel? { + return conversationsSource.conversationViewModels.first { $0.conversation?.id == call.conversationId } + } +} + +class ActiveCallRowViewModel: ObservableObject, Equatable { + @Published var title = "" + @Published var avatar: UIImage? + let call: ActiveCall + private let stateSubject: PublishSubject<State> + private let callService: CallsService + private let disposeBag = DisposeBag() + + init(call: ActiveCall, stateSubject: PublishSubject<State>, callService: CallsService, swarmInfo: SwarmInfoProtocol) { + self.call = call + self.callService = callService + self.stateSubject = stateSubject + self.subscribeToSwarmInfo(swarmInfo: swarmInfo) + } + + private func subscribeToSwarmInfo(swarmInfo: SwarmInfoProtocol) { + swarmInfo.finalTitle + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] title in + self?.title = title + }) + .disposed(by: disposeBag) + + swarmInfo.finalAvatar + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] avatar in + self?.avatar = avatar + }) + .disposed(by: disposeBag) + } + + func acceptCall() { + let uri = call.constructURI() + stateSubject.onNext(ConversationState.startCall(contactRingId: uri, userName: "")) + } + + func acceptAudioCall() { + let uri = call.constructURI() + stateSubject.onNext(ConversationState.startAudioCall(contactRingId: uri, userName: "")) + } + + func rejectCall() { + callService.ignoreCall(call: call) + } + + static func == (lhs: ActiveCallRowViewModel, rhs: ActiveCallRowViewModel) -> Bool { + return lhs.call.id == rhs.call.id && + lhs.title == rhs.title && + lhs.avatar == rhs.avatar + } +} diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift index fd1ca49fb5a066539a49496f0242f4caf9d15f53..1bf15d754a67e68b0a8f9b33b16dd992186a077f 100644 --- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift +++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift @@ -125,6 +125,7 @@ class ConversationViewController: UIViewController, tapAction.accept(true) } + // swiftlint:disable cyclomatic_complexity private func addSwiftUIView() { self.viewModel.swiftUIModel.hideNavigationBar .subscribe(onNext: { [weak self] (hide) in @@ -168,6 +169,8 @@ class ConversationViewController: UIViewController, self.importDocument() case .registerTypingIndicator(let typingStatus): self.viewModel.setIsComposingMsg(isComposing: typingStatus) + case .joinActiveCall(call: let call, withVideo: let withVideo): + self.viewModel.joinActiveCall(call: call, withVideo: withVideo) } }) .disposed(by: self.disposeBag) @@ -549,12 +552,6 @@ class ConversationViewController: UIViewController, ? viewModel.displayName.value ?? "" : viewModel.userName.value - // do not show call buttons for swarm with multiple participants - if self.viewModel.conversation.getParticipants().count > 1 { - self.navigationItem.rightBarButtonItems = [] - return - } - if self.viewModel.isConversationForBlockedContact() { self.navigationItem.rightBarButtonItems = [] return diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift index adc316ac1d47f5ae3691a9514d9ecb419c72f649..f057063532313e8458343e363cffecd76938c88d 100644 --- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift +++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift @@ -380,6 +380,40 @@ class ConversationViewModel: Stateable, ViewModel, ObservableObject, Identifiabl } } + func joinActiveCall(call: ActiveCall, withVideo: Bool) { + let callURI = call.constructURI() + if withVideo { + stateSubject.onNext(ConversationState.startCall(contactRingId: callURI, userName: "")) + } else { + stateSubject.onNext(ConversationState.startAudioCall(contactRingId: callURI, userName: "")) + } + } + + private func prepareCallURI() -> String? { + guard let jamiId = self.conversation.getParticipants().first?.jamiId else { return nil } + var uri = self.conversation.isDialog() ? jamiId : "swarm:" + self.conversation.id + + if let activeCall = self.callService.getActiveCall(accountId: self.conversation.accountId, conversationId: self.conversation.id), !self.conversation.isDialog() { + uri = activeCall.constructURI() + } + + return uri + } + + func startCall() { + guard let uri = prepareCallURI() else { return } + let name = self.conversation.isDialog() ? self.displayName.value ?? self.userName.value : "" + self.closeAllPlayers() + self.stateSubject.onNext(ConversationState.startCall(contactRingId: uri, userName: name)) + } + + func startAudioCall() { + guard let uri = prepareCallURI() else { return } + let name = self.conversation.isDialog() ? self.displayName.value ?? self.userName.value : "" + self.closeAllPlayers() + self.stateSubject.onNext(ConversationState.startAudioCall(contactRingId: uri, userName: name)) + } + func sendMessage(withContent content: String, parentId: String = "", contactURI: String? = nil, conversationModel: ConversationModel? = nil) { let conversation = conversationModel ?? self.conversation guard let conversation = conversation else { return } @@ -387,9 +421,9 @@ class ConversationViewModel: Stateable, ViewModel, ObservableObject, Identifiabl /// send not swarm message guard let participantJamiId = conversation.getParticipants().first?.jamiId, let account = self.accountService.currentAccount else { return } - /// if in call send sip msg - if let call = self.callService.call(participantHash: participantJamiId, accountID: conversation.accountId) { - self.callService.sendTextMessage(callID: call.callId, message: content, accountId: account) + // if in call send sip msg + if let call = self.callService.call(participantId: participantJamiId, accountId: conversation.accountId) { + self.callService.sendInCallMessage(callID: call.callId, message: content, accountId: account) return } self.conversationsService @@ -427,18 +461,6 @@ class ConversationViewModel: Stateable, ViewModel, ObservableObject, Identifiabl } } - func startCall() { - guard let jamiId = self.conversation.getParticipants().first?.jamiId else { return } - self.closeAllPlayers() - self.stateSubject.onNext(ConversationState.startCall(contactRingId: jamiId, userName: self.displayName.value ?? self.userName.value)) - } - - func startAudioCall() { - guard let jamiId = self.conversation.getParticipants().first?.jamiId else { return } - self.closeAllPlayers() - self.stateSubject.onNext(ConversationState.startAudioCall(contactRingId: jamiId, userName: self.displayName.value ?? self.userName.value)) - } - func showContactInfo() { if self.swiftUIModel.isTemporary { return @@ -475,12 +497,12 @@ class ConversationViewModel: Stateable, ViewModel, ObservableObject, Identifiabl return false } guard let jamiId = self.conversation.getParticipants().first?.jamiId else { return false } - return self.callService.call(participantHash: jamiId, accountID: self.conversation.accountId) != nil + return self.callService.call(participantId: jamiId, accountId: self.conversation.accountId) != nil } lazy var showCallButton: Observable<Bool> = { return self.callService - .currentCallsEvents + .callUpdates .share() .asObservable() .filter({ [weak self] (call) -> Bool in @@ -523,8 +545,8 @@ class ConversationViewModel: Stateable, ViewModel, ObservableObject, Identifiabl func openCall() { guard let call = self.callService - .call(participantHash: self.conversation.getParticipants().first?.jamiId ?? "", - accountID: self.conversation.accountId) else { return } + .call(participantId: self.conversation.getParticipants().first?.jamiId ?? "", + accountId: self.conversation.accountId) else { return } self.stateSubject.onNext(ConversationState.navigateToCall(call: call)) } diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/CallBannerViewModel.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/CallBannerViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..2137c953b5049275a24b89221653f9d16b768f8a --- /dev/null +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/CallBannerViewModel.swift @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2025 - 2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import Foundation +import RxSwift +import RxRelay +import SwiftUI + +class CallBannerViewModel: ObservableObject { + @Published var isVisible = false + @Published var activeCalls: [ActiveCall] = [] + + private let callService: CallsService + private let conversation: ConversationModel + private let disposeBag = DisposeBag() + private let state: PublishSubject<State> + + init(injectionBag: InjectionBag, conversation: ConversationModel, state: PublishSubject<State>) { + self.callService = injectionBag.callService + self.conversation = conversation + self.state = state + + setupCallSubscription() + } + + private func setupCallSubscription() { + callService.activeCalls + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] accountTrackers in + guard let self = self else { return } + + let incomingCalls = accountTrackers[self.conversation.accountId]? + .incomingUnansweredCalls(for: self.conversation.id) ?? [] + + self.activeCalls = incomingCalls + self.isVisible = !incomingCalls.isEmpty + }) + .disposed(by: disposeBag) + } + + func acceptVideoCall(for call: ActiveCall) { + self.state.onNext(MessagePanelState.joinActiveCall(call: call, withVideo: true)) + } + + func acceptAudioCall(for call: ActiveCall) { + self.state.onNext(MessagePanelState.joinActiveCall(call: call, withVideo: false)) + } +} diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift index e5db09e91129566f8f354261a41a20135080ba2e..a6ac52c7af41f75ef6d3475d90c503fa50e974f4 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift @@ -45,6 +45,7 @@ enum MessagePanelState: State { case recordAudio case recordVido case sendFile + case joinActiveCall(call: ActiveCall, withVideo: Bool) func toString() -> String { switch self { @@ -64,8 +65,8 @@ enum MessagePanelState: State { return L10n.Alerts.uploadFile case .sendPhoto: return "send photo" - case .registerTypingIndicator: - return "typing indicator" + default: + return "" } } @@ -151,6 +152,8 @@ class MessagesListVM: ObservableObject { var transferHelper: TransferHelper var messagePanel: MessagePanelVM var currentlyTypingUsers: Set<String> = [] + var callBannerViewModel: CallBannerViewModel + private let injectionBag: InjectionBag // state private let contextStateSubject = PublishSubject<State>() @@ -205,6 +208,7 @@ class MessagesListVM: ObservableObject { } self.updateColorPreference() self.updateLastDisplayed() + self.callBannerViewModel = CallBannerViewModel(injectionBag: self.injectionBag, conversation: self.conversation, state: self.messagePanelStateSubject) } } @@ -218,6 +222,7 @@ class MessagesListVM: ObservableObject { @Published var typingIndicatorText = "" init (injectionBag: InjectionBag, transferHelper: TransferHelper) { + self.injectionBag = injectionBag self.requestsService = injectionBag.requestsService self.conversation = ConversationModel() self.accountService = injectionBag.accountService @@ -230,7 +235,9 @@ class MessagesListVM: ObservableObject { self.transferHelper = transferHelper self.locationSharingService = injectionBag.locationSharingService self.messagePanel = MessagePanelVM(messagePanelState: self.messagePanelStateSubject) + self.callBannerViewModel = CallBannerViewModel(injectionBag: injectionBag, conversation: self.conversation, state: self.messagePanelStateSubject) self.contextMenuModel.currentJamiAccountId = self.accountService.currentAccount?.jamiId + self.subscribeLocationEvents() self.subscribeSwarmPreferences() self.subscribeUserAvatarForLocationSharing() diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/CallBannerView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/CallBannerView.swift new file mode 100644 index 0000000000000000000000000000000000000000..1b9f2e7facfe9e36c7993c4123b76294ee8a4227 --- /dev/null +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/CallBannerView.swift @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2025 - 2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import SwiftUI + +struct CallBannerView: View { + @ObservedObject var viewModel: CallBannerViewModel + @SwiftUI.State private var isAnimating = false + + var body: some View { + VStack(spacing: 12) { + ForEach(viewModel.activeCalls, id: \.id) { call in + VStack(spacing: 12) { + Text(L10n.Calls.activeCallInConversation) + .font(.callout) + .multilineTextAlignment(.center) + HStack(spacing: 16) { + Button(action: { + viewModel.acceptVideoCall(for: call) + }, label: { + Image(systemName: "video") + .font(.system(size: 25)) + .foregroundColor(.jamiColor) + .padding(.horizontal) + }) + + Button(action: { + viewModel.acceptAudioCall(for: call) + }, label: { + Image(systemName: "phone") + .font(.system(size: 25)) + .foregroundColor(.jamiColor) + .padding(.horizontal) + }) + } + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .center) + .background( + ZStack { + Color(UIColor.systemBackground) + VisualEffect(style: .systemChromeMaterial, withVibrancy: true) + Color(UIColor.secondarySystemBackground) + .opacity(isAnimating ? 0.1 : 1.0) + } + ) + .shadow(color: Color(UIColor.tertiarySystemBackground).opacity(0.8), radius: 1, y: 2) + .cornerRadius(12) + .onAppear { + isAnimating = true + } + .animation( + .easeInOut(duration: 1.0).repeatForever(autoreverses: true), + value: isAnimating + ) + } +} diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift index bf076f97b082ac9756f7cdc086a08d862f7e200b..e1319dd484f1dcedc5ea910e9da7dcd192f51030 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift @@ -47,6 +47,7 @@ struct ScrollViewOffsetPreferenceKey: PreferenceKey { struct MessagesListView: View { @ObservedObject var model: MessagesListVM + @ObservedObject var callBannerViewModel: CallBannerViewModel @SwiftUI.State var showScrollToLatestButton = false let scrollReserved = UIScreen.main.bounds.height * 1.5 @@ -68,6 +69,11 @@ struct MessagesListView: View { @SwiftUI.State private var dotCount = 0 private let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + init(model: MessagesListVM) { + self.model = model + self.callBannerViewModel = model.callBannerViewModel + } + var body: some View { ZStack { ZStack(alignment: .top) { @@ -128,6 +134,10 @@ struct MessagesListView: View { if model.isBlocked { blockView() } + + if callBannerViewModel.isVisible { + CallBannerView(viewModel: callBannerViewModel) + } } if showReactionsView { if let reactions = reactionsForMessage { diff --git a/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift b/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift index cccc60aa9a4e7600da467d1589f1ddc6285547f5..bec15822dc8988166bc26f5177e56eb05ca4b7e1 100644 --- a/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift +++ b/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift @@ -23,6 +23,7 @@ import Foundation import RxSwift import RxCocoa import os +import SwiftUI // swiftlint:disable cyclomatic_complexity /// This Coordinator drives the conversation navigation (Smartlist / Conversation detail) @@ -107,14 +108,20 @@ class ConversationsCoordinator: RootCoordinator, StateableResponsive, Conversati }) .disposed(by: self.disposeBag) - self.callService.newCall + self.callService.sharedResponseStream .asObservable() + .filter({ event in + return event.eventType == .incomingCall + }) .observe(on: MainScheduler.instance) - .subscribe(onNext: { (call) in + .subscribe(onNext: { (event) in + guard let callId: String = event.getEventInput(.callId), + let call = self.callService.call(callID: callId) else { return } self.showIncomingCall(call: call) }) .disposed(by: self.disposeBag) self.callbackPlaceCall() + self.subscribeToActiveCalls() self.navigationController.navigationBar.tintColor = UIColor.jamiButtonDark } @@ -125,6 +132,49 @@ class ConversationsCoordinator: RootCoordinator, StateableResponsive, Conversati self.present(viewController: viewController, withStyle: .replaceNavigationStack, withAnimation: true, withStateable: view.stateEmitter) } + func subscribeToActiveCalls() { + self.injectionBag.callService.activeCalls + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] accountCalls in + guard let self = self else { return } + let hasActiveCalls = accountCalls.values.contains { accountCalls in + !accountCalls.incomingUnansweredNotIgnoredCalls().isEmpty + } + + guard hasActiveCalls else { return } + + // Skip showing call alert if the conversation for incoming call is already open + if accountCalls.count == 1 { + if accountCalls.first?.value.incomingUnansweredNotIgnoredCalls().count == 1, + let conversationId = accountCalls.first?.value.incomingUnansweredNotIgnoredCalls().first?.conversationId, + let navigationController = self.rootViewController as? UINavigationController, + let conversationModel = self.getConversationViewModelForId(conversationId: conversationId), + let conversationController = navigationController.topViewController as? ConversationViewController, + conversationController.viewModel == conversationModel { + return + } + } + + if self.presentingVC[VCType.activeCalls.rawValue] == true { + return + } + + let activeCallsViewModel = ActiveCallsViewModel( + injectionBag: self.injectionBag, conversationsSource: self.conversationsSource + ) + let activeCallsView = ActiveCallsView(viewModel: activeCallsViewModel) + let viewController = self.createHostingVC(activeCallsView) + viewController.view.backgroundColor = .clear + + self.presentFromTopController(viewController: viewController, + style: .overFullScreen, + animation: true, + stateable: activeCallsViewModel, + lockKey: VCType.activeCalls.rawValue) + }) + .disposed(by: self.disposeBag) + } + func addLockFlags() { presentingVC[VCType.contact.rawValue] = false presentingVC[VCType.conversation.rawValue] = false @@ -367,7 +417,7 @@ extension ConversationsCoordinator { } }) .disposed(by: self.disposeBag) - self.nameService.lookupAddress(withAccount: account.id, nameserver: "", address: call.participantUri.filterOutHost()) + self.nameService.lookupAddress(withAccount: account.id, nameserver: "", address: call.callUri.filterOutHost()) } diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift index 371e80908ef705268d6eb83335c3ff8629664740..b7848f83c1eb69b6d3d8b1c8f968b91d70378ce1 100644 --- a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift +++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift @@ -143,6 +143,23 @@ struct JamsSearchResultView: View { } } +struct ActiveCallIndicator: View { + @SwiftUI.State private var isAnimating = false + + var body: some View { + Image(systemName: "phone") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.jamiColor) + .padding(.horizontal) + .opacity(isAnimating ? 0.4 : 1.0) + .onAppear { + withAnimation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + isAnimating = true + } + } + } +} + struct ConversationRowView: View { @ObservedObject var model: ConversationViewModel var withSeparator: Bool = true @@ -200,6 +217,9 @@ struct ConversationRowView: View { } } Spacer() + if model.swiftUIModel.callBannerViewModel.isVisible { + ActiveCallIndicator() + } if model.unreadMessages > 0 { Text("\(model.unreadMessages)") .fontWeight(.semibold) diff --git a/Ring/Ring/Helpers/ThreadSafeQueueHelper.swift b/Ring/Ring/Helpers/ThreadSafeQueueHelper.swift new file mode 100644 index 0000000000000000000000000000000000000000..f96e5b36c0879dfd56fb7d874bcea090fb16d9ce --- /dev/null +++ b/Ring/Ring/Helpers/ThreadSafeQueueHelper.swift @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import Foundation +import RxRelay +import RxSwift + +final class ThreadSafeQueueHelper { + private let queue: DispatchQueue + + private let queueKey = DispatchSpecificKey<Bool>() + + init(label: String, qos: DispatchQoS = .userInitiated) { + self.queue = DispatchQueue(label: label, qos: qos, attributes: .concurrent) + self.queue.setSpecific(key: queueKey, value: true) + } + + init(queue: DispatchQueue) { + self.queue = queue + self.queue.setSpecific(key: queueKey, value: true) + } + + func safeSync<T>(_ work: () -> T) -> T { + if isCurrentThreadOnQueue() { + // Already on the queue, execute directly + return work() + } else { + return queue.sync { + return work() + } + } + } + + func barrierAsync(_ work: @escaping () -> Void) { + queue.async(flags: .barrier) { + work() + } + } + + /// Execute a block synchronously with a barrier if it's safe to do so + func safeBarrierSync<T>(_ work: () -> T) -> T { + if isCurrentThreadOnQueue() { + // Already on the queue, direct execution to avoid deadlock + // Note: This loses the barrier guarantee but prevents deadlock + return work() + } else { + return queue.sync(flags: .barrier) { + return work() + } + } + } + + func isCurrentThreadOnQueue() -> Bool { + return DispatchQueue.getSpecific(key: queueKey) == true + } + + var underlyingQueue: DispatchQueue { + return queue + } +} + +final class ThreadSafeValue<T> { + private var value: T + private let queueHelper: ThreadSafeQueueHelper + + init(_ initialValue: T, queueHelper: ThreadSafeQueueHelper) { + self.value = initialValue + self.queueHelper = queueHelper + } + + func get() -> T { + queueHelper.safeSync { + value + } + } + + func update(_ block: @escaping (inout T) -> Void) { + queueHelper.barrierAsync { + block(&self.value) + } + } + + func set(_ newValue: T) { + queueHelper.barrierAsync { + self.value = newValue + } + } +} + +final class SynchronizedRelay<T> { + private let relay: BehaviorRelay<T> + private let queueHelper: ThreadSafeQueueHelper + + init(initialValue: T, queueHelper: ThreadSafeQueueHelper) { + self.relay = BehaviorRelay(value: initialValue) + self.queueHelper = queueHelper + } + + var observable: Observable<T> { + relay.asObservable() + } + + func get() -> T { + queueHelper.safeSync { + relay.value + } + } + + func set(_ newValue: T) { + queueHelper.barrierAsync { + self.relay.accept(newValue) + } + } + + func update(_ block: @escaping (inout T) -> Void) { + queueHelper.barrierAsync { + var value = self.relay.value + block(&value) + self.relay.accept(value) + } + } +} diff --git a/Ring/Ring/Helpers/VideoInputsManager.swift b/Ring/Ring/Helpers/VideoInputsManager.swift index cf2c9cb81798fe7fb1624f2c4004e9971ed4ca2c..d089f66492ac180074a4c89b577ca8fc83825aa2 100644 --- a/Ring/Ring/Helpers/VideoInputsManager.swift +++ b/Ring/Ring/Helpers/VideoInputsManager.swift @@ -62,7 +62,8 @@ class VideoInputsManager { func writeFrame(withBuffer buffer: CVPixelBuffer?, sinkId: String, rotation: Int) { guard let sampleBuffer = self.createSampleBufferFrom(pixelBuffer: buffer) else { - return } + return + } self.setSampleBufferAttachments(sampleBuffer) let frameInfo = VideoFrameInfo(sampleBuffer: sampleBuffer, rotation: rotation, sinkId: sinkId) frameSubject.onNext(frameInfo) diff --git a/Ring/Ring/Models/CallModel.swift b/Ring/Ring/Models/CallModel.swift index 13728f8ed949e012a5905b555da9ace579b60af7..b88e0d334b147f106928ffae8cba86de0ecd5afe 100644 --- a/Ring/Ring/Models/CallModel.swift +++ b/Ring/Ring/Models/CallModel.swift @@ -48,6 +48,10 @@ enum CallState: String { } } + func isFinished() -> Bool { + return self == .over || self == .hungup || self == .failure + } + func isActive() -> Bool { return self == .incoming || self == .connecting || self == .ringing || self == .current || self == .hold || self == .unhold } @@ -112,10 +116,17 @@ public class CallModel { } var callUUID: UUID = UUID() var dateReceived: Date? - var participantUri: String = "" + var callUri: String = "" { + didSet { + if !callUri.isEmpty { + self.updateConversationId() + } + } + } var displayName: String = "" var registeredName: String = "" var accountId: String = "" + var conversationId: String = "" var audioMuted: Bool = false var callRecorded: Bool = false var videoMuted: Bool = false @@ -124,7 +135,7 @@ public class CallModel { var isAudioOnly: Bool = false var layout: CallLayout = .one lazy var paricipantHash = { - self.participantUri.filterOutHost() + self.callUri.filterOutHost() } var mediaList: [[String: String]] = [[String: String]]() @@ -162,7 +173,7 @@ public class CallModel { self.callId = callId if let fromRingId = dictionary[CallDetailKey.peerNumberKey.rawValue] { - self.participantUri = fromRingId + self.callUri = fromRingId } if let accountId = dictionary[CallDetailKey.accountIdKey.rawValue] { @@ -225,7 +236,7 @@ public class CallModel { } if let participantRingId = dictionary[CallDetailKey.peerNumberKey.rawValue] { - self.participantUri = participantRingId + self.callUri = participantRingId } if let accountId = dictionary[CallDetailKey.accountIdKey.rawValue] { @@ -253,4 +264,31 @@ public class CallModel { func isActive() -> Bool { return self.state == .connecting || self.state == .ringing || self.state == .current } + + func isCurrent() -> Bool { + return self.state == .current || self.state == .hold || + self.state == .unhold || self.state == .ringing + } + + func updateParticipantsCallId(callId: String) { + participantsCallId.removeAll() + participantsCallId.insert(callId) + } + + func updateWith(callId: String, callDictionary: [String: String], participantId: String) { + self.update(withDictionary: callDictionary, withMedia: self.mediaList) + self.callUri = participantId + self.callId = callId + self.updateParticipantsCallId(callId: callId) + } + + func getactiveCallFromURI() -> ActiveCall? { + return ActiveCall(self.callUri) + } + + func updateConversationId() { + if let activeCall = getactiveCallFromURI() { + self.conversationId = activeCall.conversationId + } + } } diff --git a/Ring/Ring/Protocols/ConversationNavigation.swift b/Ring/Ring/Protocols/ConversationNavigation.swift index 438045cf386a7e9586bc49894b2811eb82620a70..3bc6b9bf18117d24617c23c475e839254c2ad215 100644 --- a/Ring/Ring/Protocols/ConversationNavigation.swift +++ b/Ring/Ring/Protocols/ConversationNavigation.swift @@ -249,17 +249,6 @@ extension ConversationNavigation where Self: Coordinator, Self: StateableRespons withStateable: callViewController.viewModel) } - func getTopController() -> UIViewController? { - guard var topController = UIApplication.shared - .keyWindow?.rootViewController else { - return nil - } - while let presentedViewController = topController.presentedViewController { - topController = presentedViewController - } - return topController - } - func startOutgoingCall(contactRingId: String, userName: String, isAudioOnly: Bool = false) { guard let topController = getTopController(), !topController.isKind(of: (CallViewController).self), @@ -270,7 +259,7 @@ extension ConversationNavigation where Self: Coordinator, Self: StateableRespons topController.dismiss(animated: false, completion: nil) let callViewController = CallViewController.instantiate(with: self.injectionBag) self.present(viewController: callViewController, - withStyle: .fadeInOverFullScreen, + withStyle: .popToRootAndPush, withAnimation: false, withStateable: callViewController.viewModel) callViewController.viewModel.placeCall(with: contactRingId, userName: userName, account: account, isAudioOnly: isAudioOnly) diff --git a/Ring/Ring/Protocols/Coordinator.swift b/Ring/Ring/Protocols/Coordinator.swift index c532df94692a4b441208d3c6571f6d4c1628c866..edb116b9a635c36e02f19206b6e73caa660b173a 100644 --- a/Ring/Ring/Protocols/Coordinator.swift +++ b/Ring/Ring/Protocols/Coordinator.swift @@ -187,6 +187,16 @@ extension Coordinator { return hostingController } + func getTopController() -> UIViewController? { + guard var topController = UIApplication.shared.windows.first?.rootViewController else { + return nil + } + while let presentedViewController = topController.presentedViewController { + topController = presentedViewController + } + return topController + } + private func dismiss(viewController: UIViewController, animated: Bool) { if let navigationController = viewController.navigationController { navigationController.popViewController(animated: animated) diff --git a/Ring/Ring/Protocols/StateableResponsive.swift b/Ring/Ring/Protocols/StateableResponsive.swift index df46e82b4707441944636281db2619b7ab457e8d..f4d18e5fc793b1a221542442d9ba6cecb9c8d644 100644 --- a/Ring/Ring/Protocols/StateableResponsive.swift +++ b/Ring/Ring/Protocols/StateableResponsive.swift @@ -57,4 +57,41 @@ extension StateableResponsive where Self: Coordinator { }) .disposed(by: self.disposeBag) } + + /// Present a view controller from the top-most view controller + /// + /// - Parameters: + /// - viewController: The ViewController to present + /// - style: The presentation style + /// - animation: Whether the transition should be animated + /// - stateable: The stateable that will feed the inner stateSubject + /// - lockKey: The key to use for presentation locking + func presentFromTopController(viewController: UIViewController, + style: UIModalPresentationStyle, + animation: Bool, + stateable: Stateable? = nil, + lockKey: String? = nil) { + guard let topController = getTopController() else { return } + + viewController.modalPresentationStyle = style + viewController.modalTransitionStyle = .crossDissolve + + if let lockKey = lockKey { + self.presentingVC[lockKey] = true + } + + topController.present(viewController, animated: animation) { [weak self] in + if let lockKey = lockKey { + self?.presentingVC[lockKey] = false + } + } + + if let stateable = stateable { + stateable.state.take(until: viewController.rx.deallocated) + .subscribe(onNext: { [weak self] (state) in + self?.stateSubject.onNext(state) + }) + .disposed(by: self.disposeBag) + } + } } diff --git a/Ring/Ring/Resources/en.lproj/Localizable.strings b/Ring/Ring/Resources/en.lproj/Localizable.strings index f559baa930e92280f5fcfb46b9006c190087cc7e..53af45ee322cb1d9dc713e6443172ba3a35e0bfd 100644 --- a/Ring/Ring/Resources/en.lproj/Localizable.strings +++ b/Ring/Ring/Resources/en.lproj/Localizable.strings @@ -301,6 +301,8 @@ "calls.muteAudio" = "Mute microphone"; "calls.unmuteAudio" = "Unmute microphone"; "calls.lowerHand" = "Lower hand"; +"calls.activeCallLabel" = "A call is in progress. Do you want to join the call? You can also join the call later from the conversation."; +"calls.activeCallInConversation" = "A call is in progress. Do you want to join the call?"; // Account Page "accountPage.devicesListHeader" = "Devices"; diff --git a/Ring/Ring/Services/Calls/ActiveCallsHelper.swift b/Ring/Ring/Services/Calls/ActiveCallsHelper.swift new file mode 100644 index 0000000000000000000000000000000000000000..400256a4311ebee7f57fd9eb0c948bcaaadea81e --- /dev/null +++ b/Ring/Ring/Services/Calls/ActiveCallsHelper.swift @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import Foundation +import RxRelay + +struct ActiveCall: Hashable { + let id: String + let uri: String + let device: String + let conversationId: String + let accountId: String + let isFromLocalDevice: Bool + + func constructURI() -> String { + return "rdv:" + self.conversationId + "/" + self.uri + "/" + self.device + "/" + self.id + } + + init(id: String, uri: String, device: String, conversationId: String, accountId: String, + isFromLocalDevice: Bool) { + self.id = id + self.uri = uri + self.device = device + self.conversationId = conversationId + self.accountId = accountId + self.isFromLocalDevice = isFromLocalDevice + } + + init?(_ raw: String) { + let components = raw.replacingOccurrences(of: "rdv:", with: "").split(separator: "/") + guard components.count == 4 else { return nil } + self.conversationId = String(components[0]) + self.uri = String(components[1]) + self.device = String(components[2]) + self.id = String(components[3]) + self.isFromLocalDevice = false + self.accountId = "" + } +} + +struct AccountCallTracker { + private var calls: [String: [ActiveCall]] = [:] + private var ignoredCalls: [String: Set<ActiveCall>] = [:] + private var answeredCalls: [String: Set<ActiveCall>] = [:] + + var allConversationIds: [String] { + Array(calls.keys) + } + + mutating func setCalls(for conversationId: String, to newCalls: [ActiveCall]) { + calls[conversationId] = newCalls + if newCalls.isEmpty { + ignoredCalls[conversationId] = [] + answeredCalls[conversationId] = [] + } + } + + mutating func ignoreCall(_ call: ActiveCall) { + ignoredCalls[call.conversationId, default: Set()].insert(call) + } + + mutating func answerCall(_ call: ActiveCall) { + answeredCalls[call.conversationId, default: Set()].insert(call) + } + + func calls(for conversationId: String) -> [ActiveCall] { + calls[conversationId] ?? [] + } + + func ignoredCalls(for conversationId: String) -> Set<ActiveCall> { + ignoredCalls[conversationId] ?? [] + } + + func answeredCalls(for conversationId: String) -> Set<ActiveCall> { + answeredCalls[conversationId] ?? [] + } + + mutating func removeAnsweredCall(_ call: ActiveCall) { + answeredCalls[call.conversationId]?.remove(call) + } + + func notAnsweredCalls(for conversationId: String) -> [ActiveCall] { + calls(for: conversationId).filter { !answeredCalls(for: conversationId).contains($0) } + } + + func notIgnoredCalls(for conversationId: String) -> [ActiveCall] { + calls(for: conversationId).filter { !ignoredCalls(for: conversationId).contains($0) } + } + + func incomingUnansweredCalls(for conversationId: String) -> [ActiveCall] { + return notAnsweredCalls(for: conversationId) + .filter { !$0.isFromLocalDevice } + } + + func incomingUnansweredNotIgnoredCalls() -> [ActiveCall] { + allConversationIds.flatMap { conversationId in + let answered = answeredCalls(for: conversationId) + return notIgnoredCalls(for: conversationId) + .filter { !$0.isFromLocalDevice && !answered.contains($0) } + } + } +} + +class ActiveCallsHelper { + var activeCalls = BehaviorRelay<[String: AccountCallTracker]>(value: [:]) + + func updateActiveCalls(conversationId: String, calls: [[String: String]], account: AccountModel) { + let parsedCalls: [ActiveCall] = calls.compactMap { dict -> ActiveCall? in + guard let id = dict["id"], let uri = dict["uri"], let device = dict["device"] else { + return nil + } + + let currentDeviceId = account.devices.first(where: \.isCurrent)?.deviceId ?? "" + let isLocal = uri == account.jamiId && device == currentDeviceId + + return ActiveCall( + id: id, + uri: uri, + device: device, + conversationId: conversationId, + accountId: account.id, + isFromLocalDevice: isLocal + ) + } + + var calls = activeCalls.value + var callTracker = calls[account.id] ?? AccountCallTracker() + callTracker.setCalls(for: conversationId, to: parsedCalls) + calls[account.id] = callTracker + activeCalls.accept(calls) + } + + func ignoreCall(_ call: ActiveCall) { + var calls = activeCalls.value + var callTracker = calls[call.accountId] ?? AccountCallTracker() + callTracker.ignoreCall(call) + calls[call.accountId] = callTracker + activeCalls.accept(calls) + } + + func answerCall(_ callURI: String) { + guard let parsed = ActiveCall.init(callURI), + let (accountId, call) = findActiveCall(conversationId: parsed.conversationId, callId: parsed.id) else { + return + } + + var calls = activeCalls.value + var callTracker = calls[call.accountId] ?? AccountCallTracker() + callTracker.answerCall(call) + calls[accountId] = callTracker + activeCalls.accept(calls) + } + + private func findActiveCall(conversationId: String, callId: String) -> (accountId: String, call: ActiveCall)? { + for (accountId, state) in activeCalls.value { + if let call = state.calls(for: conversationId).first(where: { $0.id == callId }) { + return (accountId, call) + } + } + return nil + } + + func getActiveCall(conversationId: String, accountId: String) -> ActiveCall? { + for (accountId, state) in activeCalls.value { + return state.calls(for: conversationId).first + } + return nil + } + + func hasRemoteActiveCalls() -> Bool { + return activeCalls.value.values.contains { state in + !state.allConversationIds + .flatMap { state.notIgnoredCalls(for: $0) } + .filter { !$0.isFromLocalDevice } + .isEmpty + } + } + + func activeCallHangedUp(callURI: String) { + guard let parsed = ActiveCall.init(callURI), + let (accountId, call) = findActiveCall(conversationId: parsed.conversationId, callId: parsed.id) else { + return + } + + /// Removes the call from answered calls list to allow rejoining, + /// and adds it to ignored calls to prevent call alerts from showing + var calls = activeCalls.value + var callTracker = calls[call.accountId] ?? AccountCallTracker() + callTracker.ignoreCall(call) + callTracker.removeAnsweredCall(call) + calls[accountId] = callTracker + activeCalls.accept(calls) + } +} diff --git a/Ring/Ring/Services/Calls/CallManagementService.swift b/Ring/Ring/Services/Calls/CallManagementService.swift new file mode 100644 index 0000000000000000000000000000000000000000..3cd8e36ed018f6daae59ba8caf81db303843efa0 --- /dev/null +++ b/Ring/Ring/Services/Calls/CallManagementService.swift @@ -0,0 +1,382 @@ +/* + * Copyright (C) 2017-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import RxSwift +import RxRelay + +enum CallServiceError: Error, LocalizedError { + case acceptCallFailed + case refuseCallFailed + case hangUpCallFailed + case holdCallFailed + case unholdCallFailed + case placeCallFailed + case callNotFound + case invalidUUID + + var errorDescription: String? { + switch self { + case .acceptCallFailed: + return "Failed to accept call" + case .refuseCallFailed: + return "Failed to refuse call" + case .hangUpCallFailed: + return "Failed to hang up call" + case .holdCallFailed: + return "Failed to hold call" + case .unholdCallFailed: + return "Failed to unhold call" + case .placeCallFailed: + return "Failed to place call" + case .callNotFound: + return "Call not found" + case .invalidUUID: + return "Invalid call UUID" + } + } +} + +class CallManagementService { + // MARK: - Properties + + private let callsAdapter: CallsAdapter + private let calls: SynchronizedRelay<[String: CallModel]> + private let callUpdates: ReplaySubject<CallModel> + private let responseStream: PublishSubject<ServiceEvent> + private let disposeBag = DisposeBag() + + // MARK: - Initialization + + init( + callsAdapter: CallsAdapter, + calls: SynchronizedRelay<[String: CallModel]>, + callUpdates: ReplaySubject<CallModel>, + responseStream: PublishSubject<ServiceEvent> + ) { + self.callsAdapter = callsAdapter + self.calls = calls + self.callUpdates = callUpdates + self.responseStream = responseStream + } + + // MARK: - Call Access + + func call(callId: String) -> CallModel? { + return calls.get()[callId] + } + + func call(participantId: String, accountId: String) -> CallModel? { + return calls.get().values.first(where: { $0.paricipantHash() == participantId && $0.accountId == accountId }) + } + + func callByUUID(UUID: String) -> CallModel? { + return calls.get().values.first(where: { $0.callUUID.uuidString == UUID }) + } + + // MARK: - Call Management + + func accept(callId: String) -> Completable { + return createObservableAction(callId: callId, error: .acceptCallFailed) { [weak self] call in + guard let self = self else { return false } + return self.callsAdapter.acceptCall(withId: call.callId, accountId: call.accountId, withMedia: call.mediaList) + } + } + + func refuse(callId: String) -> Completable { + return createObservableAction(callId: callId, error: .refuseCallFailed) { [weak self] call in + guard let self = self else { return false } + return self.callsAdapter.refuseCall(withId: callId, accountId: call.accountId) + } + } + + func hangUp(callId: String) -> Completable { + return createObservableAction(callId: callId, error: .hangUpCallFailed) { [weak self] call in + guard let self = self else { return false } + return self.callsAdapter.hangUpCall(callId, accountId: call.accountId) + } + } + + func hold(callId: String) -> Completable { + return createObservableAction(callId: callId, error: .holdCallFailed) { [weak self] call in + guard let self = self else { return false } + return self.callsAdapter.holdCall(withId: callId, accountId: call.accountId) + } + } + + func unhold(callId: String) -> Completable { + return createObservableAction(callId: callId, error: .unholdCallFailed) { [weak self] call in + guard let self = self else { return false } + return self.callsAdapter.unholdCall(withId: callId, accountId: call.accountId) + } + } + + func placeCall(withAccount account: AccountModel, + toParticipantId participantId: String, + userName: String, + videoSource: String, + isAudioOnly: Bool = false, + withMedia: [[String: String]] = [[String: String]]()) -> Single<CallModel> { + + return prepareCallModel( + account: account, + participantId: participantId, + userName: userName, + videoSource: videoSource, + isAudioOnly: isAudioOnly, + withMedia: withMedia + ) + .flatMap { [weak self] callModel -> Single<CallModel> in + guard let self = self else { + return Single.error(CallServiceError.placeCallFailed) + } + return self.executeCall(callModel: callModel, account: account, participantId: participantId) + } + } + + private func prepareCallModel( + account: AccountModel, + participantId: String, + userName: String, + videoSource: String, + isAudioOnly: Bool, + withMedia: [[String: String]] + ) -> Single<CallModel> { + let mediaList = withMedia.isEmpty ? + MediaAttributeFactory.createDefaultMediaList(isAudioOnly: isAudioOnly, videoSource: videoSource) : + withMedia + + let call = CallModelFactory.createOutgoingCall( + participantId: participantId, + accountId: account.id, + userName: userName, + isAudioOnly: isAudioOnly, + withMedia: mediaList + ) + + return Single.just(call) + } + + private func executeCall( + callModel: CallModel, + account: AccountModel, + participantId: String + ) -> Single<CallModel> { + return Single<CallModel>.create { [weak self] single in + guard let self = self else { + single(.failure(CallServiceError.placeCallFailed)) + return Disposables.create() + } + + let callId = self.initiateCall(account: account, participantId: participantId, mediaList: callModel.mediaList) + if callId.isEmpty { + single(.failure(CallServiceError.placeCallFailed)) + return Disposables.create() + } + + guard let callDictionary = self.getCallDetails(callId: callId, accountId: account.id) else { + single(.failure(CallServiceError.placeCallFailed)) + return Disposables.create() + } + + callModel.updateWith(callId: callId, callDictionary: callDictionary, participantId: participantId) + self.updateCallsStore(callModel, forId: callId) + + // self.emitCallStarted(call: callModel) + single(.success(callModel)) + + return Disposables.create() + } + } + + private func initiateCall(account: AccountModel, participantId: String, mediaList: [[String: String]]) -> String { + guard let callId = self.callsAdapter.placeCall( + withAccountId: account.id, + toParticipantId: participantId, + withMedia: mediaList + ) else { + return "" + } + return callId + } + + func getCallDetails(callId: String, accountId: String) -> [String: String]? { + return self.callsAdapter.callDetails( + withCallId: callId, + accountId: accountId + ) + } + + func createSwarmCallModel(conference: (confId: String, conversationId: String, accountId: String), isAudioOnly: Bool) -> CallModel { + let call = CallModel() + call.state = .connecting + call.callType = .outgoing + call.accountId = conference.accountId + call.callId = conference.confId + call.conversationId = conference.conversationId + call.isAudioOnly = isAudioOnly + + calls.update { calls in + calls[call.callId] = call + } + + return call + } + + func isCurrentCall() -> Bool { + return calls.get().values.contains { $0.isCurrent() } + } + + // MARK: - Call State Management + + func addOrUpdateCall(callId: String, callState: CallState, callDictionary: [String: String], mediaList: [[String: String]] = [[String: String]](), notifyIncoming: Bool = false) -> CallModel? { + var call = self.calls.get()[callId] + // var isNewCall = false + // var previousState: CallState? + + if call == nil { + if !callState.isActive() { + return nil + } + call = CallModel(withCallId: callId, callDetails: callDictionary, withMedia: mediaList) + // isNewCall = true + call?.state = callState + updateCallsStore(call!, forId: callId) + } else { + // previousState = call?.state + call?.update(withDictionary: callDictionary, withMedia: mediaList) + call?.state = callState + } + + guard let updatedCall = call else { return nil } + self.callUpdates.onNext(updatedCall) + + if notifyIncoming { + notifyIncomingCall(call: updatedCall) + } + return updatedCall + } + + private func notifyIncomingCall(call: CallModel) { + var event = ServiceEvent(withEventType: .incomingCall) + event.addEventInput(.peerUri, value: call.callUri) + event.addEventInput(.callUUID, value: call.callUUID.uuidString) + event.addEventInput(.accountId, value: call.accountId) + event.addEventInput(.callId, value: call.callId) + self.responseStream.onNext(event) + } + + func removeCall(callId: String, callState: CallState) { + guard let finishedCall = self.call(callId: callId) else { + return + } + + finishedCall.state = callState + + let callDuration = self.calculateCallDuration(finishedCall) + self.emitCallEnded(call: finishedCall, duration: callDuration) + + self.callUpdates.onNext(finishedCall) + + self.calls.update { calls in + calls[callId] = nil + } + } + + func updateCallUUID(callId: String, callUUID: String) { + guard let call = self.call(callId: callId), + let uuid = UUID(uuidString: callUUID) else { + return + } + + call.callUUID = uuid + } + + // MARK: - Private Helpers + + private func createObservableAction(callId: String, error: CallServiceError, action: @escaping (CallModel) -> Bool) -> Completable { + return Completable.create { [weak self] completable in + guard let self = self else { + completable(.error(CallServiceError.callNotFound)) + return Disposables.create() + } + + guard let call = self.call(callId: callId) else { + completable(.error(CallServiceError.callNotFound)) + return Disposables.create() + } + + let success = action(call) + if success { + completable(.completed) + } else { + completable(.error(error)) + } + + return Disposables.create() + } + } + + private func updateCallsStore(_ call: CallModel, forId callId: String) { + self.calls.update { calls in + calls[callId] = call + } + } + + private func calculateCallDuration(_ call: CallModel) -> Int { + guard let startTime = call.dateReceived else { return 0 } + return Int(Date().timeIntervalSince1970 - startTime.timeIntervalSince1970) + } + + // MARK: - Event Emission + + private func emitCallEnded(call: CallModel, duration: Int) { + var event = ServiceEvent(withEventType: .callEnded) + configureBasicCallEvent(&event, for: call) + event.addEventInput(.callTime, value: duration) + self.responseStream.onNext(event) + } + + private func configureBasicCallEvent(_ event: inout ServiceEvent, for call: CallModel) { + event.addEventInput(.peerUri, value: call.callUri) + event.addEventInput(.callUUID, value: call.callUUID.uuidString) + event.addEventInput(.accountId, value: call.accountId) + event.addEventInput(.callId, value: call.callId) + event.addEventInput(.callType, value: call.callType.rawValue) + } +} + +class CallModelFactory { + static func createOutgoingCall(participantId: String, + accountId: String, + userName: String, + isAudioOnly: Bool, + withMedia mediaList: [[String: String]]) -> CallModel { + var callDetails = [String: String]() + callDetails[CallDetailKey.callTypeKey.rawValue] = String(describing: CallType.outgoing) + callDetails[CallDetailKey.displayNameKey.rawValue] = userName + callDetails[CallDetailKey.accountIdKey.rawValue] = accountId + callDetails[CallDetailKey.audioOnlyKey.rawValue] = isAudioOnly.toString() + callDetails[CallDetailKey.timeStampStartKey.rawValue] = "" + + let call = CallModel(withCallId: participantId, callDetails: callDetails, withMedia: mediaList) + call.state = .unknown + call.callType = .outgoing + call.callUri = participantId + return call + } +} diff --git a/Ring/Ring/Services/Calls/CallsService.swift b/Ring/Ring/Services/Calls/CallsService.swift new file mode 100644 index 0000000000000000000000000000000000000000..31355202ee680f5be031ffc28afca3f7a1c9a272 --- /dev/null +++ b/Ring/Ring/Services/Calls/CallsService.swift @@ -0,0 +1,543 @@ +/* + * Copyright (C) 2017-2025 Savoir-faire Linux Inc. + * + * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com> + * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import RxSwift +import RxRelay +import SwiftyBeaver +import Contacts + +typealias CallsDictionary = [String: CallModel] +typealias PendingConferencesType = [String: Set<String>] +typealias ConferenceInfosType = [String: [ConferenceParticipant]] + +struct SwarmCallRequest { + let conversationId: String + let account: AccountModel + let uri: String + let userName: String + let videoSource: String + let isAudioOnly: Bool + let timestamp: Date +} + +/// Main service responsible for coordinating all call-related operations +class CallsService: CallsAdapterDelegate { + // Service instances + private let callManagementService: CallManagementService + private let conferenceManagementService: ConferenceManagementService + private let mediaManagementService: MediaManagementService + private let messageHandlingService: MessageHandlingService + private let activeCallsHelper = ActiveCallsHelper() + + private let callsAdapter: CallsAdapter + let dbManager: DBManager + private let disposeBag = DisposeBag() + private let queueHelper: ThreadSafeQueueHelper + + var calls: SynchronizedRelay<CallsDictionary> + let callUpdates = ReplaySubject<CallModel>.create(bufferSize: 1) + + private let responseStream = PublishSubject<ServiceEvent>() + var sharedResponseStream: Observable<ServiceEvent> + private let newMessagesStream = PublishSubject<ServiceEvent>() + var newMessage: Observable<ServiceEvent> + + var activeCalls: Observable<[String: AccountCallTracker]> { + return activeCallsHelper.activeCalls.asObservable() + } + + var currentConferenceEvent: BehaviorRelay<ConferenceUpdates> { + return conferenceManagementService.currentConferenceEvent + } + + private let swarmCallCreated = PublishSubject<(confId: String, conversationId: String, accountId: String)>() + private var waitingSwarmCall = "" + + init(withCallsAdapter callsAdapter: CallsAdapter, dbManager: DBManager) { + self.callsAdapter = callsAdapter + self.dbManager = dbManager + + self.queueHelper = ThreadSafeQueueHelper(label: "com.ring.callsManagement", qos: .userInitiated) + + self.responseStream.disposed(by: disposeBag) + self.sharedResponseStream = responseStream.share() + newMessage = newMessagesStream.share() + + self.calls = SynchronizedRelay<CallsDictionary>(initialValue: [:], queueHelper: queueHelper) + + self.callManagementService = CallManagementService( + callsAdapter: callsAdapter, + calls: calls, + callUpdates: callUpdates, + responseStream: responseStream + ) + + self.conferenceManagementService = ConferenceManagementService( + callsAdapter: callsAdapter, + calls: calls, + callUpdates: callUpdates + ) + + self.mediaManagementService = MediaManagementService( + callsAdapter: callsAdapter, + calls: calls, + callUpdates: callUpdates, + responseStream: responseStream + ) + + self.messageHandlingService = MessageHandlingService( + callsAdapter: callsAdapter, + dbManager: dbManager, + calls: calls, + newMessagesStream: newMessagesStream + ) + + CallsAdapter.delegate = self + + monitorCalls() + } + + private func monitorCalls() { + self.calls.observable + .subscribe(onNext: { calls in + if calls.isEmpty { + NotificationCenter.default.post(name: NSNotification.Name(NotificationName.restoreDefaultVideoDevice.rawValue), object: nil, userInfo: nil) + } + }) + .disposed(by: self.disposeBag) + } + + // MARK: - CallsAdapterDelegate methods + + func didChangeCallState(withCallId callId: String, state: String, accountId: String, stateCode: NSInteger) { + guard let callDictionary = self.callsAdapter.callDetails(withCallId: callId, accountId: accountId) else { return } + + let callState = CallState(rawValue: state) ?? CallState.unknown + + if callState.isFinished() { + handleCallTermination(callId: callId, callState: callState) + return + } + + if let call = self.callManagementService.addOrUpdateCall(callId: callId, callState: callState, callDictionary: callDictionary) { + processCallStateChange(call: call, callId: callId, accountId: accountId) + } + } + + private func handleCallTermination(callId: String, callState: CallState) { + self.callManagementService.removeCall(callId: callId, callState: callState) + self.conferenceManagementService.clearPendingConferences(callId: callId) + Task { + await self.conferenceManagementService.updateConferences(callId: callId) + } + } + + private func processCallStateChange(call: CallModel, callId: String, accountId: String) { + if shouldSendVCard(for: call) { + self.messageHandlingService.sendVCard(callID: callId, accountID: call.accountId) + } + + if call.state == .current { + self.joinConferenceIfNeeded(callId: callId, accountId: accountId) + } + } + + private func shouldSendVCard(for call: CallModel) -> Bool { + return (call.state == .ringing && call.callType == .outgoing) || + (call.state == .current && call.callType == .incoming) + } + + func didChangeMediaNegotiationStatus(withCallId callId: String, event: String, withMedia: [[String: String]]) { + Task { + await mediaManagementService.handleMediaNegotiationStatus(callId: callId, event: event, media: withMedia) + } + } + + func didReceiveMediaChangeRequest(withAccountId accountId: String, callId: String, withMedia: [[String: String]]) { + Task { + await mediaManagementService.handleMediaChangeRequest(accountId: accountId, callId: callId, media: withMedia) + } + } + + func getVideoCodec(call: CallModel) -> String? { + return mediaManagementService.getVideoCodec(call: call) + } + + func didReceiveMessage(withCallId callId: String, fromURI uri: String, message: [String: String]) { + messageHandlingService.handleIncomingMessage(callId: callId, fromURI: uri, message: message) + } + + func receivingCall(withAccountId accountId: String, callId: String, fromURI uri: String, withMedia: [[String: String]]) { + guard let callDictionary = self.callsAdapter.callDetails(withCallId: callId, accountId: accountId) else { return } + + _ = self.callManagementService.addOrUpdateCall(callId: callId, callState: .incoming, callDictionary: callDictionary, mediaList: withMedia, notifyIncoming: true) + } + + func callPlacedOnHold(withCallId callId: String, holding: Bool) { + Task { + await mediaManagementService.handleCallPlacedOnHold(callId: callId, holding: holding) + } + } + + func conferenceCreated(conferenceId: String, conversationId: String, accountId: String) { + if !conversationId.isEmpty && self.waitingSwarmCall.contains(conversationId) { + // For swarm calls, we provide the confId, conversationId, and accountId + // to be picked up by the placeSwarmCall subscription + swarmCallCreated.onNext((confId: conferenceId, conversationId: conversationId, accountId: accountId)) + } + + conferenceManagementService.handleConferenceCreated(conferenceId: conferenceId, conversationId: conversationId, accountId: accountId) + } + + func conferenceChanged(conference conferenceID: String, accountId: String, state: String) { + Task { + await conferenceManagementService.handleConferenceChanged(conference: conferenceID, accountId: accountId, state: state) + } + } + + func conferenceRemoved(conference conferenceID: String) { + Task { + await conferenceManagementService.handleConferenceRemoved(conference: conferenceID) + } + } + + func remoteRecordingChanged(call callId: String, record: Bool) { + Task { + await mediaManagementService.handleRemoteRecordingChanged(callId: callId, record: record) + } + } + + func conferenceInfoUpdated(conference conferenceID: String, info: [[String: String]]) { + Task { + await conferenceManagementService.handleConferenceInfoUpdated(conference: conferenceID, info: info) + } + } + + func audioMuted(call callId: String, mute: Bool) { + Task { + await mediaManagementService.audioMuted(call: callId, mute: mute) + } + } + + func videoMuted(call callId: String, mute: Bool) { + Task { + await mediaManagementService.videoMuted(call: callId, mute: mute) + } + } + + func callMediaUpdated(call: CallModel) { + Task { + await mediaManagementService.callMediaUpdated(call: call) + } + } + + func updateCallMediaIfNeeded(call: CallModel) async { + await mediaManagementService.updateCallMediaIfNeeded(call: call) + } + + func handleRemoteRecordingChanged(call callId: String, record: Bool) { + Task { + await mediaManagementService.handleRemoteRecordingChanged(callId: callId, record: record) + } + } + + func handleCallPlacedOnHold(callId: String, holding: Bool) { + Task { + await mediaManagementService.handleCallPlacedOnHold(callId: callId, holding: holding) + } + } + + func handleMediaNegotiationStatus(callId: String, event: String, media: [[String: String]]) { + Task { + await mediaManagementService.handleMediaNegotiationStatus(callId: callId, event: event, media: media) + } + } + + func handleMediaChangeRequest(accountId: String, callId: String, media: [[String: String]]) { + Task { + await mediaManagementService.handleMediaChangeRequest(accountId: accountId, callId: callId, media: media) + } + } + + func currentCall(callId: String) -> Observable<CallModel> { + return self.callUpdates + .share() + .filter { (call) -> Bool in + call.callId == callId + } + .asObservable() + } + + func inConferenceCalls() -> PublishSubject<CallModel> { + return self.conferenceManagementService.inConferenceCalls + } + + func call(callID: String) -> CallModel? { + return callManagementService.call(callId: callID) + } + + func call(participantId: String, accountId: String) -> CallModel? { + return callManagementService.call(participantId: participantId, accountId: accountId) + } + + func callByUUID(UUID: String) -> CallModel? { + return callManagementService.callByUUID(UUID: UUID) + } + + func accept(callId: String) -> Completable { + return callManagementService.accept(callId: callId) + } + + func refuse(callId: String) -> Completable { + return callManagementService.refuse(callId: callId) + } + + func hangUp(callId: String) -> Completable { + return callManagementService.hangUp(callId: callId) + } + + func hold(callId: String) -> Completable { + return callManagementService.hold(callId: callId) + } + + func unhold(callId: String) -> Completable { + return callManagementService.unhold(callId: callId) + } + + func placeSwarmCall(withAccount account: AccountModel, uri: String, userName: String, videoSource: String, isAudioOnly: Bool) -> Single<CallModel> { + waitingSwarmCall = uri + // When conference is created, conferenceCreated will be called. We need to wait for that and create a call after that. + return Single.create { [weak self] single in + guard let self = self else { + single(.failure(CallServiceError.placeCallFailed)) + return Disposables.create() + } + + // Extract the conversation ID from the swarm URI + let conversationId = uri.replacingOccurrences(of: "swarm:", with: "") + + let timeoutWorkItem = DispatchWorkItem { + single(.failure(CallServiceError.placeCallFailed)) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 30.0, execute: timeoutWorkItem) + + // Create a subscription to monitor conference creation + let subscription = self.swarmCallCreated + .filter { $0.conversationId == conversationId } + .take(1) + .subscribe(onNext: { conference in + let call = self.callManagementService.createSwarmCallModel(conference: conference, isAudioOnly: isAudioOnly) + timeoutWorkItem.cancel() + single(.success(call)) + }) + + // Initiate the call process + let callDisposable = self.callManagementService.placeCall( + withAccount: account, + toParticipantId: uri, + userName: userName, + videoSource: videoSource, + isAudioOnly: isAudioOnly + ) + .subscribe( + onSuccess: { _ in + // We don't expect this to succeed with a call model for swarm calls + // The actual call will be obtained via conference events + }, + onFailure: { error in + if error as? CallServiceError != CallServiceError.placeCallFailed { + timeoutWorkItem.cancel() + single(.failure(error)) + } + } + ) + + return Disposables.create { + timeoutWorkItem.cancel() + subscription.dispose() + callDisposable.dispose() + } + } + } + + func placeCall(withAccount account: AccountModel, toParticipantId participantId: String, userName: String, videoSource: String, isAudioOnly: Bool) -> Single<CallModel> { + if participantId.contains("rdv:") { + self.activeCallsHelper.answerCall(participantId) + } + return callManagementService.placeCall(withAccount: account, toParticipantId: participantId, userName: userName, videoSource: videoSource, isAudioOnly: isAudioOnly) + } + + func getActiveCall(accountId: String, conversationId: String) -> ActiveCall? { + return self.activeCallsHelper.getActiveCall(conversationId: conversationId, accountId: accountId) + } + + func answerCall(call: CallModel) -> Bool { + return self.callsAdapter.acceptCall(withId: call.callId, accountId: call.accountId, withMedia: call.mediaList) + } + + func stopCall(call: CallModel) { + self.callsAdapter.hangUpCall(call.callId, accountId: call.accountId) + } + + func playDTMF(code: String) { + self.callsAdapter.playDTMF(code) + } + + func isCurrentCall() -> Bool { + return callManagementService.isCurrentCall() + } + + func updateCallUUID(callId: String, callUUID: String) { + callManagementService.updateCallUUID(callId: callId, callUUID: callUUID) + } + + // MARK: - Conference handling methods + + func activeCallsChanged(conversationId: String, accountId: String, calls: [[String: String]], account: AccountModel) { + activeCallsHelper.updateActiveCalls(conversationId: conversationId, calls: calls, account: account) + } + + func ignoreCall(call: ActiveCall) { + self.activeCallsHelper.ignoreCall(call) + } + + func joinConferenceIfNeeded(callId: String, accountId: String) { + guard let confId = conferenceManagementService.shouldCallBeAddedToConference(callId: callId) else { + return + } + + guard let pendingCall = self.call(callID: callId) else { + return + } + + // Using a delay to ensure call is fully established before joining to a conference + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self = self else { return } + self.addCallToConference( + pendingCall: pendingCall, + callToAdd: callId, + confId: confId, + callAccountId: accountId + ) + } + } + + func addCallToConference(pendingCall: CallModel, callToAdd: String, confId: String, callAccountId: String) { + if pendingCall.participantsCallId.count == 1 { + self.callsAdapter.joinCall(confId, second: callToAdd, accountId: pendingCall.accountId, account2Id: callAccountId) + } else { + self.callsAdapter.joinConference(confId, call: callToAdd, accountId: pendingCall.accountId, account2Id: callAccountId) + } + } + + func joinConference(confID: String, callID: String) { + conferenceManagementService.joinConference(confID: confID, callID: callID) + } + + func joinCall(firstCallId: String, secondCallId: String) { + conferenceManagementService.joinCall(firstCallId: firstCallId, secondCallId: secondCallId) + } + + func callAndAddParticipant(participant contactId: String, toCall callId: String, withAccount account: AccountModel, userName: String, videSource: String, isAudioOnly: Bool = false) { + self.placeCall(withAccount: account, + toParticipantId: contactId, + userName: userName, + videoSource: videSource, + isAudioOnly: isAudioOnly) + .subscribe { [weak self] callModel in + guard let self = self else { return } + self.conferenceManagementService.addCall(call: callModel, to: callId) + + } + .disposed(by: self.disposeBag) + } + + func hangUpCallOrConference(callId: String, isSwarm: Bool, callURI: String) { + conferenceManagementService.hangUpCallOrConference(callId: callId, isSwarm: isSwarm) + .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) + .observe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) + .subscribe(onCompleted: { + self.activeCallsHelper.activeCallHangedUp(callURI: callURI) + }) + .disposed(by: self.disposeBag) + } + + func isParticipant(participantURI: String?, activeIn conferenceId: String, accountId: String) -> Bool? { + return conferenceManagementService.isParticipant(participantURI: participantURI, activeIn: conferenceId, accountId: accountId) + } + + func isModerator(participantId: String, inConference confId: String) -> Bool { + return conferenceManagementService.isModerator(participantId: participantId, inConference: confId) + } + + func getConferenceParticipants(for conferenceId: String) -> [ConferenceParticipant]? { + return conferenceManagementService.getConferenceParticipants(for: conferenceId) + } + + func setActiveParticipant(conferenceId: String, maximixe: Bool, jamiId: String) { + conferenceManagementService.setActiveParticipant(conferenceId: conferenceId, maximixe: maximixe, jamiId: jamiId) + } + + func setModeratorParticipant(confId: String, participantId: String, active: Bool) { + conferenceManagementService.setModeratorParticipant(confId: confId, participantId: participantId, active: active) + } + + func hangupParticipant(confId: String, participantId: String, device: String) { + conferenceManagementService.hangupParticipant(confId: confId, participantId: participantId, device: device) + } + + func muteStream(confId: String, participantId: String, device: String, accountId: String, streamId: String, state: Bool) { + conferenceManagementService.muteStream(confId: confId, participantId: participantId, device: device, accountId: accountId, streamId: streamId, state: state) + } + + func setRaiseHand(confId: String, participantId: String, state: Bool, accountId: String, deviceId: String) { + conferenceManagementService.setRaiseHand(confId: confId, participantId: participantId, state: state, accountId: accountId, deviceId: deviceId) + } + + func clearPendingConferences(callId: String) { + conferenceManagementService.clearPendingConferences(callId: callId) + } + + func updateConferences(callId: String) { + Task { + await conferenceManagementService.updateConferences(callId: callId) + } + } + + func shouldCallBeAddedToConference(callId: String) -> String? { + return conferenceManagementService.shouldCallBeAddedToConference(callId: callId) + } + + // MARK: - MessageHandling implementation (delegating to MessageHandlingService) + + func sendVCard(callID: String, accountID: String) { + messageHandlingService.sendVCard(callID: callID, accountID: accountID) + } + + func sendInCallMessage(callID: String, message: String, accountId: AccountModel) { + messageHandlingService.sendInCallMessage(callID: callID, message: message, accountId: accountId) + } + + func sendChunk(callID: String, message: [String: String], accountId: String, from: String) { + messageHandlingService.sendChunk(callID: callID, message: message, accountId: accountId, from: from) + } +} diff --git a/Ring/Ring/Services/Calls/ConferenceManagementService.swift b/Ring/Ring/Services/Calls/ConferenceManagementService.swift new file mode 100644 index 0000000000000000000000000000000000000000..ee18b657555ee1e8b22e1bde95371d755427c02e --- /dev/null +++ b/Ring/Ring/Services/Calls/ConferenceManagementService.swift @@ -0,0 +1,505 @@ +/* + * Copyright (C) 2017-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import RxSwift +import RxRelay + +enum ConferenceState: String { + case conferenceCreated + case conferenceDestroyed + case infoUpdated + case activeAttachd +} + +typealias ConferenceUpdates = (conferenceID: String, state: String, calls: Set<String>) + +class ConferenceManagementService { + private let callsAdapter: CallsAdapter + private let calls: SynchronizedRelay<[String: CallModel]> + private let callUpdates: ReplaySubject<CallModel> + + private let pendingConferencesRelay = BehaviorRelay<PendingConferencesType>(value: [:]) + let createdConferencesRelay = BehaviorRelay<Set<String>>(value: []) + private let conferenceInfosRelay = BehaviorRelay<ConferenceInfosType>(value: [:]) + + private let disposeBag = DisposeBag() + + let inConferenceCalls = PublishSubject<CallModel>() + let currentConferenceEvent: BehaviorRelay<ConferenceUpdates> + + init( + callsAdapter: CallsAdapter, + calls: SynchronizedRelay<[String: CallModel]>, + callUpdates: ReplaySubject<CallModel> + ) { + self.callsAdapter = callsAdapter + self.calls = calls + self.callUpdates = callUpdates + self.currentConferenceEvent = BehaviorRelay<ConferenceUpdates>(value: ("", "", Set<String>())) + } + + // MARK: - Conference Management + + func joinConference(confID: String, callID: String) { + guard let secondConf = calls.get()[callID], + let firstConf = calls.get()[confID] else { return } + + updatePendingConferences(mainCallId: confID, callToAdd: callID) + + if secondConf.participantsCallId.count == 1 { + callsAdapter.joinConference(confID, call: callID, accountId: firstConf.accountId, account2Id: secondConf.accountId) + } else { + callsAdapter.joinConferences(confID, secondConference: callID, accountId: firstConf.accountId, account2Id: secondConf.accountId) + } + } + + func joinCall(firstCallId: String, secondCallId: String) { + guard let firstCall = calls.get()[firstCallId], + let secondCall = calls.get()[secondCallId] else { return } + + updatePendingConferences(mainCallId: firstCallId, callToAdd: secondCallId) + callsAdapter.joinCall(firstCallId, second: secondCallId, accountId: firstCall.accountId, account2Id: secondCall.accountId) + } + + func addCall(call: CallModel, to callId: String) { + updatePendingConferences(mainCallId: callId, callToAdd: call.callId) + self.inConferenceCalls.onNext(call) + } + + func hangUpCallOrConference(callId: String, isSwarm: Bool) -> Completable { + return Completable.create { [weak self] completable in + guard let self = self, + let call = self.calls.get()[callId] else { + completable(.error(CallServiceError.hangUpCallFailed)) + return Disposables.create() + } + + let success = self.hangUpCall(callId: callId, call: call, isSwarm: isSwarm) + + if success { + completable(.completed) + } else { + completable(.error(CallServiceError.hangUpCallFailed)) + } + + return Disposables.create() + } + } + + private func hangUpCall(callId: String, call: CallModel, isSwarm: Bool) -> Bool { + if call.participantsCallId.count >= 2 || isSwarm { + return callsAdapter.hangUpConference(callId, accountId: call.accountId) || + callsAdapter.hangUpCall(callId, accountId: call.accountId) + } else { + return callsAdapter.hangUpCall(callId, accountId: call.accountId) + } + } + + // MARK: - Participant Management + + /// Checks if a participant is active in a conference + /// - Returns: true if the participant is active, false if not, nil if no participant + func isParticipant(participantURI: String?, activeIn conferenceId: String, accountId: String) -> Bool? { + guard let uri = participantURI else { return false } + guard let participants = conferenceInfosRelay.value[conferenceId] else { + guard let participantsArray = callsAdapter.getConferenceInfo(conferenceId, accountId: accountId) as? [[String: String]] else { + return nil + } + + let normalizedURI = uri.filterOutHost() + for participant in participantsArray { + if let participantURI = participant["uri"]?.filterOutHost(), + participantURI == normalizedURI { + return ConferenceParticipant(info: participant, onlyURIAndActive: true).isActive + } + } + + return nil + } + + let normalizedURI = uri.filterOutHost() + return participants.first(where: { $0.uri?.filterOutHost() == normalizedURI })?.isActive + } + + func isModerator(participantId: String, inConference confId: String) -> Bool { + guard let participants = conferenceInfosRelay.value[confId] else { return false } + + return participants.first(where: { + $0.uri?.filterOutHost() == participantId.filterOutHost() + })?.isModerator ?? false + } + + func getConferenceParticipants(for conferenceId: String) -> [ConferenceParticipant]? { + return conferenceInfosRelay.value[conferenceId] + } + + func setActiveParticipant(conferenceId: String, maximixe: Bool, jamiId: String) { + guard let conference = self.calls.get()[conferenceId], + let isActive = self.isParticipant(participantURI: jamiId, activeIn: conferenceId, accountId: conference.accountId) else { + return + } + + let newLayout = isActive ? self.getNewLayoutForActiveParticipant(currentLayout: conference.layout, maximixe: maximixe) : .oneWithSmal + conference.layout = newLayout + + self.callsAdapter.setActiveParticipant(jamiId, forConference: conferenceId, accountId: conference.accountId) + self.callsAdapter.setConferenceLayout(newLayout.rawValue, forConference: conferenceId, accountId: conference.accountId) + } + + func setModeratorParticipant(confId: String, participantId: String, active: Bool) { + guard let conference = calls.get()[confId] else { return } + callsAdapter.setConferenceModerator(participantId, forConference: confId, accountId: conference.accountId, active: active) + } + + func hangupParticipant(confId: String, participantId: String, device: String) { + guard let conference = calls.get()[confId] else { return } + callsAdapter.hangupConferenceParticipant(participantId, forConference: confId, accountId: conference.accountId, deviceId: device) + } + + func muteStream(confId: String, participantId: String, device: String, accountId: String, streamId: String, state: Bool) { + callsAdapter.muteStream(participantId, forConference: confId, accountId: accountId, deviceId: device, streamId: streamId, state: state) + } + + func setRaiseHand(confId: String, participantId: String, state: Bool, accountId: String, deviceId: String) { + callsAdapter.raiseHand(participantId, forConference: confId, accountId: accountId, deviceId: deviceId, state: state) + } + + // MARK: - Conference State Management + + func updateConferences(callId: String) async { + calls.update { calls in + let conferencesWithCall = calls.keys.filter { conferenceId -> Bool in + guard let callModel = calls[conferenceId] else { return false } + return callModel.participantsCallId.count > 1 && callModel.participantsCallId.contains(callId) + } + + guard let conferenceId = conferencesWithCall.first, + let conference = calls[conferenceId] else { return } + + let conferenceCalls = Set(self.callsAdapter.getConferenceCalls(conferenceId, accountId: conference.accountId)) + + conference.participantsCallId = conferenceCalls + + for callId in conferenceCalls { + if let call = calls[callId] { + call.participantsCallId = conferenceCalls + } + } + } + } + + private func getConferenceParticipants(conferenceId: String, accountId: String) -> Set<String> { + return Set(self.callsAdapter.getConferenceCalls(conferenceId, accountId: accountId)) + } + + private func registerEmptyConference(conferenceId: String) { + var createdConferences = self.createdConferencesRelay.value + createdConferences.insert(conferenceId) + self.createdConferencesRelay.accept(createdConferences) + } + + private func markConferenceAsProcessed(conferenceId: String) { + var createdConferences = self.createdConferencesRelay.value + createdConferences.remove(conferenceId) + self.createdConferencesRelay.accept(createdConferences) + } + + private func processPendingParticipants( + conferenceId: String, + conferenceCalls: Set<String> + ) -> (sourceCallId: String?, updatedPendingConferences: [String: Set<String>]) { + let pendingConferences = self.pendingConferencesRelay.value + var updatedPendingConferences = pendingConferences + var sourceCallId: String? + + for (callId, pendingSet) in pendingConferences { + if conferenceCalls.contains(callId) && !conferenceCalls.isDisjoint(with: pendingSet) { + sourceCallId = callId + + // Identify which invited participants haven't joined yet + var waitingParticipants = pendingSet + waitingParticipants.subtract(conferenceCalls) + + // Update pending conferences tracking + updatedPendingConferences[callId] = nil + if !waitingParticipants.isEmpty { + updatedPendingConferences[conferenceId] = waitingParticipants + } + break + } + } + + return (sourceCallId, updatedPendingConferences) + } + + private func linkParticipantsToConference(conferenceCalls: Set<String>) { + calls.update { calls in + for callId in conferenceCalls { + if let call = calls[callId] { + call.participantsCallId = conferenceCalls + } + } + } + } + + private func createConferenceObject( + conferenceId: String, + sourceCallId: String, + conferenceCalls: Set<String>, + accountId: String + ) -> Bool { + guard let sourceCall = calls.get()[sourceCallId], + var callDetails = self.callsAdapter.getConferenceDetails(conferenceId, accountId: accountId) else { + return false + } + + callDetails[CallDetailKey.accountIdKey.rawValue] = sourceCall.accountId + callDetails[CallDetailKey.audioOnlyKey.rawValue] = sourceCall.isAudioOnly.toString() + + let conferenceModel = CallModel(withCallId: conferenceId, callDetails: callDetails, withMedia: [[String: String]]()) + conferenceModel.participantsCallId = conferenceCalls + + calls.update { calls in + calls[conferenceId] = conferenceModel + } + + return true + } + + private func notifyConferenceReady(conferenceId: String, conferenceCalls: Set<String>) { + self.currentConferenceEvent.accept((conferenceId, ConferenceState.conferenceCreated.rawValue, conferenceCalls)) + } + + private func updatePendingParticipantsTracking(updatedPendingConferences: [String: Set<String>]) { + let pendingConferences = self.pendingConferencesRelay.value + if updatedPendingConferences != pendingConferences { + self.pendingConferencesRelay.accept(updatedPendingConferences) + } + } + + func handleConferenceCreated(conferenceId: String, conversationId: String, accountId: String) { + let conferenceCalls = self.getConferenceParticipants(conferenceId: conferenceId, accountId: accountId) + + if conferenceCalls.isEmpty { + self.registerEmptyConference(conferenceId: conferenceId) + return + } + + self.markConferenceAsProcessed(conferenceId: conferenceId) + + let (sourceCallId, updatedPendingConferences) = self.processPendingParticipants( + conferenceId: conferenceId, + conferenceCalls: conferenceCalls + ) + + self.linkParticipantsToConference(conferenceCalls: conferenceCalls) + + var conferenceCreated = false + if let sourceId = sourceCallId { + conferenceCreated = self.createConferenceObject( + conferenceId: conferenceId, + sourceCallId: sourceId, + conferenceCalls: conferenceCalls, + accountId: accountId + ) + } + + if conferenceCreated { + self.notifyConferenceReady(conferenceId: conferenceId, conferenceCalls: conferenceCalls) + } + + self.updatePendingParticipantsTracking(updatedPendingConferences: updatedPendingConferences) + } + + func handleConferenceChanged(conference conferenceId: String, accountId: String, state: String) async { + if state == "ACTIVE_ATTACHED" { + if let call = calls.get()[conferenceId] { + // For swarm calls, when a participant is attached, update the call state + if call.state == .connecting { + call.state = .current + self.callUpdates.onNext(call) + } + } + self.currentConferenceEvent.accept((conferenceId, state, [""])) + } + // Check if it's a newly created conference that needs processing + let createdConferences = self.createdConferencesRelay.value + if createdConferences.contains(conferenceId) { + self.handleConferenceCreated(conferenceId: conferenceId, conversationId: "", accountId: accountId) + return + } + + calls.update { calls in + guard let conference = calls[conferenceId] else { return } + + let conferenceCalls = Set(self.callsAdapter.getConferenceCalls(conferenceId, accountId: conference.accountId)) + + conference.participantsCallId = conferenceCalls + + for callId in conferenceCalls { + if let call = calls[callId] { + call.participantsCallId = conferenceCalls + } + } + + self.currentConferenceEvent.accept((conferenceId, state, conferenceCalls)) + } + } + + func handleConferenceRemoved(conference conferenceId: String) async { + guard let conference = calls.get()[conferenceId] else { return } + + let participantCallIds = conference.participantsCallId + + var conferenceInfos = self.conferenceInfosRelay.value + conferenceInfos.removeValue(forKey: conferenceId) + self.conferenceInfosRelay.accept(conferenceInfos) + + var pendingConferences = self.pendingConferencesRelay.value + pendingConferences[conferenceId] = nil + + var updatedEntries = [String: Set<String>]() + var keysToRemove = [String]() + + for (callId, pendingCalls) in pendingConferences { + if pendingCalls.contains(conferenceId) { + var updatedCalls = pendingCalls + updatedCalls.remove(conferenceId) + + if updatedCalls.isEmpty { + keysToRemove.append(callId) + } else { + updatedEntries[callId] = updatedCalls + } + } + } + + for (callId, updatedCalls) in updatedEntries { + pendingConferences[callId] = updatedCalls + } + + for keyToRemove in keysToRemove { + pendingConferences.removeValue(forKey: keyToRemove) + } + + self.pendingConferencesRelay.accept(pendingConferences) + + var createdConferences = self.createdConferencesRelay.value + createdConferences.remove(conferenceId) + self.createdConferencesRelay.accept(createdConferences) + + self.currentConferenceEvent.accept((conferenceId, ConferenceState.infoUpdated.rawValue, [""])) + self.currentConferenceEvent.accept((conferenceId, ConferenceState.conferenceDestroyed.rawValue, participantCallIds)) + + calls.update { calls in + calls[conferenceId] = nil + } + } + + func handleConferenceInfoUpdated(conference conferenceId: String, info: [[String: String]]) async { + let participants = self.arrayToConferenceParticipants(participants: info, onlyURIAndActive: false) + + var conferenceInfos = self.conferenceInfosRelay.value + let previousParticipants = conferenceInfos[conferenceId] ?? [] + conferenceInfos[conferenceId] = participants + self.conferenceInfosRelay.accept(conferenceInfos) + + let participantAttached = previousParticipants.count < participants.count + + if participantAttached, + let call = self.calls.get()[conferenceId], + participants.count > 1 { + if call.state == .connecting { + call.state = .current + } + } + + self.currentConferenceEvent.accept((conferenceId, ConferenceState.infoUpdated.rawValue, [""])) + } + + func clearPendingConferences(callId: String) { + var pendingConferences = self.pendingConferencesRelay.value + pendingConferences[callId] = nil + + var updatedEntries = [String: Set<String>]() + var keysToRemove = [String]() + + for (conferenceId, pendingCalls) in pendingConferences { + if pendingCalls.contains(callId) { + var updatedCalls = pendingCalls + updatedCalls.remove(callId) + + if updatedCalls.isEmpty { + keysToRemove.append(conferenceId) + } else { + updatedEntries[conferenceId] = updatedCalls + } + } + } + + for (conferenceId, updatedCalls) in updatedEntries { + pendingConferences[conferenceId] = updatedCalls + } + + for keyToRemove in keysToRemove { + pendingConferences.removeValue(forKey: keyToRemove) + } + + self.pendingConferencesRelay.accept(pendingConferences) + } + + func shouldCallBeAddedToConference(callId: String) -> String? { + let pendingConferences = pendingConferencesRelay.value + + for (conferenceId, pendingCalls) in pendingConferences { + if !pendingCalls.isEmpty && pendingCalls.contains(callId) { + return conferenceId + } + } + + return nil + } + + private func updatePendingConferences(mainCallId: String, callToAdd: String) { + var pendingConferences = self.pendingConferencesRelay.value + + if var pendingCalls = pendingConferences[mainCallId] { + pendingCalls.insert(callToAdd) + pendingConferences[mainCallId] = pendingCalls + } else { + pendingConferences[mainCallId] = [callToAdd] + } + + self.pendingConferencesRelay.accept(pendingConferences) + } + + private func getNewLayoutForActiveParticipant(currentLayout: CallLayout, maximixe: Bool) -> CallLayout { + switch currentLayout { + case .grid: + return .oneWithSmal + case .oneWithSmal: + return maximixe ? .one : .grid + case .one: + return .oneWithSmal + } + } + + private func arrayToConferenceParticipants(participants: [[String: String]], onlyURIAndActive: Bool) -> [ConferenceParticipant] { + return participants.map { ConferenceParticipant(info: $0, onlyURIAndActive: onlyURIAndActive) } + } +} diff --git a/Ring/Ring/Services/Calls/MediaManagementService.swift b/Ring/Ring/Services/Calls/MediaManagementService.swift new file mode 100644 index 0000000000000000000000000000000000000000..143a2cb130ac18b367e2f542d24ff4ff98868b3c --- /dev/null +++ b/Ring/Ring/Services/Calls/MediaManagementService.swift @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2017-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import RxRelay +import RxSwift + +enum MediaType: String, CustomStringConvertible { + case audio = "MEDIA_TYPE_AUDIO" + case video = "MEDIA_TYPE_VIDEO" + + var description: String { + return self.rawValue + } +} + +final class MediaManagementService { + + private let callsAdapter: CallsAdapter + private let calls: SynchronizedRelay<[String: CallModel]> + private let callUpdates: ReplaySubject<CallModel> + private let responseStream: PublishSubject<ServiceEvent> + + init( + callsAdapter: CallsAdapter, + calls: SynchronizedRelay<[String: CallModel]>, + callUpdates: ReplaySubject<CallModel>, + responseStream: PublishSubject<ServiceEvent> + ) { + self.callsAdapter = callsAdapter + self.calls = calls + self.callUpdates = callUpdates + self.responseStream = responseStream + } + + func getVideoCodec(call: CallModel) -> String? { + let callDetails = callsAdapter.callDetails(withCallId: call.callId, accountId: call.accountId) + return callDetails?[CallDetailKey.videoCodec.rawValue] + } + + func audioMuted(call callId: String, mute: Bool) async { + guard let call = self.getCall(with: callId) else { return } + + call.audioMuted = mute + self.updateCall(call) + } + + func videoMuted(call callId: String, mute: Bool) async { + guard let call = self.getCall(with: callId) else { return } + + call.videoMuted = mute + self.updateCall(call) + } + + func callMediaUpdated(call: CallModel) async { + guard let call = self.getCall(with: call.callId) else { return } + var mediaList = call.mediaList + + if mediaList.isEmpty { + guard let attributes = self.callsAdapter.currentMediaList(withCallId: call.callId, accountId: call.accountId) else { return } + mediaList = attributes + } + + if let callDictionary = self.callsAdapter.callDetails(withCallId: call.callId, accountId: call.accountId) { + call.update(withDictionary: callDictionary, withMedia: mediaList) + self.updateCall(call) + } + } + + func updateCallMediaIfNeeded(call: CallModel) async { + guard let call = self.getCall(with: call.callId) else { return } + var mediaList = call.mediaList + + if mediaList.isEmpty { + guard let attributes = self.callsAdapter.currentMediaList(withCallId: call.callId, accountId: call.accountId) else { return } + mediaList = attributes + + // Only update if media list has changed + if !self.compareMediaLists(call.mediaList, mediaList) { + call.mediaList = mediaList + self.updateCall(call) + } + } + } + + func handleRemoteRecordingChanged(callId: String, record: Bool) async { + guard let call = self.getCall(with: callId) else { return } + + call.callRecorded = record + self.updateCall(call) + } + + func handleCallPlacedOnHold(callId: String, holding: Bool) async { + guard let call = self.getCall(with: callId) else { return } + + call.peerHolding = holding + self.updateCall(call) + } + + func handleMediaNegotiationStatus(callId: String, event: String, media: [[String: String]]) async { + guard let call = self.getCall(with: callId), + let callDictionary = self.callsAdapter.callDetails(withCallId: callId, accountId: call.accountId) else { return } + + call.update(withDictionary: callDictionary, withMedia: media) + self.updateCall(call) + } + + func handleMediaChangeRequest(accountId: String, callId: String, media: [[String: String]]) async { + guard let call = self.getCall(with: callId) else { return } + + let answerMedias = self.processMediaChangeRequest(call: call, requestedMedia: media) + self.callsAdapter.answerMediaChangeResquest(callId, accountId: accountId, withMedia: answerMedias) + + guard let updatedCall = self.getCall(with: callId) else { return } + + updatedCall.mediaList = answerMedias + self.updateCall(updatedCall) + } + + func processMediaChangeRequest(call: CallModel, requestedMedia: [[String: String]]) -> [[String: String]] { + var answerMedias = [[String: String]]() + + for media in requestedMedia { + var answerMedia = media + + // Keep existing values for muted and enabled states if this is an existing media type + if let mediaType = media[MediaAttributeKey.mediaType.rawValue], + let mediaLabel = media[MediaAttributeKey.label.rawValue], + let existingMedia = call.mediaList.first(where: { $0[MediaAttributeKey.label.rawValue] == mediaLabel && $0[MediaAttributeKey.mediaType.rawValue] == mediaType }) { + + answerMedia[MediaAttributeKey.muted.rawValue] = existingMedia[MediaAttributeKey.muted.rawValue] ?? "false" + answerMedia[MediaAttributeKey.enabled.rawValue] = existingMedia[MediaAttributeKey.enabled.rawValue] ?? "true" + } else { + // For new media types, set defaults + if media[MediaAttributeKey.mediaType.rawValue] == MediaAttributeValue.video.rawValue { + answerMedia[MediaAttributeKey.muted.rawValue] = "true" + } else { + answerMedia[MediaAttributeKey.muted.rawValue] = "false" + } + + answerMedia[MediaAttributeKey.enabled.rawValue] = "true" + } + + answerMedias.append(answerMedia) + } + + return answerMedias + } + + private func getCall(with callId: String) -> CallModel? { + return calls.get()[callId] + } + + private func updateCall(_ call: CallModel, notify: Bool = true) { + self.calls.update { calls in + calls[call.callId] = call + } + if notify { + self.notifyCallUpdated(call) + } + } + + private func notifyCallUpdated(_ call: CallModel) { + callUpdates.onNext(call) + } + + private func compareMediaLists(_ list1: [[String: String]], _ list2: [[String: String]]) -> Bool { + guard list1.count == list2.count else { return false } + + for (index, media1) in list1.enumerated() { + let media2 = list2[index] + if media1.count != media2.count { return false } + + for (key, value) in media1 { + if media2[key] != value { return false } + } + } + + return true + } +} + +final class MediaAttributeFactory { + static func createAudioMedia() -> [String: String] { + [ + MediaAttributeKey.mediaType.rawValue: MediaAttributeValue.audio.rawValue, + MediaAttributeKey.label.rawValue: "audio_0", + MediaAttributeKey.enabled.rawValue: "true", + MediaAttributeKey.muted.rawValue: "false" + ] + } + + static func createVideoMedia(source: String) -> [String: String] { + [ + MediaAttributeKey.mediaType.rawValue: MediaAttributeValue.video.rawValue, + MediaAttributeKey.label.rawValue: "video_0", + MediaAttributeKey.source.rawValue: source, + MediaAttributeKey.enabled.rawValue: "true", + MediaAttributeKey.muted.rawValue: "false" + ] + } + + static func createDefaultMediaList(isAudioOnly: Bool, videoSource: String) -> [[String: String]] { + var mediaList = [createAudioMedia()] + + if !isAudioOnly { + mediaList.append(createVideoMedia(source: videoSource)) + } + + return mediaList + } +} diff --git a/Ring/Ring/Services/Calls/MessageHandlingService.swift b/Ring/Ring/Services/Calls/MessageHandlingService.swift new file mode 100644 index 0000000000000000000000000000000000000000..1d7ace12c4aa020385588cdbcb619a380f7c9cfa --- /dev/null +++ b/Ring/Ring/Services/Calls/MessageHandlingService.swift @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2017-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import RxSwift +import RxRelay + +protocol MessageHandling: VCardSender { + func sendInCallMessage(callID: String, message: String, accountId: AccountModel) + func handleIncomingMessage(callId: String, fromURI: String, message: [String: String]) + func sendChunk(callID: String, message: [String: String], accountId: String, from: String) +} + +final class MessageHandlingService: MessageHandling { + + private enum Constants { + static let ringVCardMIMETypePrefix = "x-ring/ring.profile.vcard;" + static let messagePrefix = "text/plain" + } + + private let callsAdapter: CallsAdapter + private let dbManager: DBManager + private let calls: SynchronizedRelay<[String: CallModel]> + private let newMessagesStream: PublishSubject<ServiceEvent> + + init( + callsAdapter: CallsAdapter, + dbManager: DBManager, + calls: SynchronizedRelay<[String: CallModel]>, + newMessagesStream: PublishSubject<ServiceEvent> + ) { + self.callsAdapter = callsAdapter + self.dbManager = dbManager + self.calls = calls + self.newMessagesStream = newMessagesStream + } + + func sendVCard(callID: String, accountID: String) { + guard !accountID.isEmpty, !callID.isEmpty else { return } + + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self, + let profile = self.dbManager.accountVCard(for: accountID) else { return } + + let jamiId = profile.uri + VCardUtils.sendVCard(card: profile, + callID: callID, + accountID: accountID, + sender: self, + from: jamiId) + } + } + + func sendInCallMessage(callID: String, message: String, accountId: AccountModel) { + guard let call = calls.get()[callID] else { return } + + let messageDictionary = [Constants.messagePrefix: message] + callsAdapter.sendTextMessage(withCallID: callID, + accountId: accountId.id, + message: messageDictionary, + from: call.paricipantHash(), + isMixed: true) + + notifyOutgoingMessage(message: message, call: call, accountId: accountId) + } + + func sendChunk(callID: String, message: [String: String], accountId: String, from: String) { + callsAdapter.sendTextMessage(withCallID: callID, + accountId: accountId, + message: message, + from: from, + isMixed: true) + } + + func handleIncomingMessage(callId: String, fromURI: String, message: [String: String]) { + guard let call = calls.get()[callId] else { return } + + if isVCardMessage(message) { + handleVCardMessage(fromURI: fromURI, call: call, message: message) + return + } + + notifyIncomingMessage(fromURI: fromURI, call: call, messageContent: message.values.first) + } + + private func isVCardMessage(_ message: [String: String]) -> Bool { + return message.keys.contains { $0.hasPrefix(Constants.ringVCardMIMETypePrefix) } + } + + private func handleVCardMessage(fromURI: String, call: CallModel, message: [String: String]) { + var data = [String: Any]() + data[ProfileNotificationsKeys.ringID.rawValue] = fromURI + data[ProfileNotificationsKeys.accountId.rawValue] = call.accountId + data[ProfileNotificationsKeys.message.rawValue] = message + + NotificationCenter.default.post( + name: NSNotification.Name(ProfileNotifications.messageReceived.rawValue), + object: nil, + userInfo: data + ) + } + + private func notifyOutgoingMessage(message: String, call: CallModel, accountId: AccountModel) { + let accountHelper = AccountModelHelper(withAccount: accountId) + let type = accountHelper.isAccountSip() ? URIType.sip : URIType.ring + + let contactUri = JamiURI(schema: type, infoHash: call.callUri, account: accountId) + guard let stringUri = contactUri.uriString, let uri = accountHelper.uri else { return } + + var event = ServiceEvent(withEventType: .newOutgoingMessage) + event.addEventInput(.content, value: message) + event.addEventInput(.peerUri, value: stringUri) + event.addEventInput(.accountId, value: accountId.id) + event.addEventInput(.accountUri, value: uri) + + newMessagesStream.onNext(event) + } + + private func notifyIncomingMessage(fromURI: String, call: CallModel, messageContent: String?) { + let accountId = call.accountId + let displayName = call.displayName + let registeredName = call.registeredName + let name = !displayName.isEmpty ? displayName : registeredName + + var event = ServiceEvent(withEventType: .newIncomingMessage) + event.addEventInput(.content, value: messageContent) + event.addEventInput(.peerUri, value: fromURI.filterOutHost()) + event.addEventInput(.name, value: name) + event.addEventInput(.accountId, value: accountId) + + newMessagesStream.onNext(event) + } +} diff --git a/Ring/Ring/Services/CallsAdapterDelegate.swift b/Ring/Ring/Services/CallsAdapterDelegate.swift index 9884eb934adb2a04bb49fba641e4cb5e1d3a4751..cda74ca18a687a241416e453224d3953323a7a6b 100644 --- a/Ring/Ring/Services/CallsAdapterDelegate.swift +++ b/Ring/Ring/Services/CallsAdapterDelegate.swift @@ -28,7 +28,7 @@ func audioMuted(call callId: String, mute: Bool) func videoMuted(call callId: String, mute: Bool) func remoteRecordingChanged(call callId: String, record: Bool) - func conferenceCreated(conference conferenceID: String, accountId: String) + func conferenceCreated(conferenceId: String, conversationId: String, accountId: String) func conferenceChanged(conference conferenceID: String, accountId: String, state: String) func conferenceRemoved(conference conferenceID: String) func conferenceInfoUpdated(conference conferenceID: String, info: [[String: String]]) diff --git a/Ring/Ring/Services/CallsProviderService.swift b/Ring/Ring/Services/CallsProviderService.swift index e798c973004228235fdda12e939425da5b127b4c..740c8a69dd4f9ba2327b781b958e3b57c4eb765b 100644 --- a/Ring/Ring/Services/CallsProviderService.swift +++ b/Ring/Ring/Services/CallsProviderService.swift @@ -207,7 +207,7 @@ extension CallsProviderService { func getHandleInfo(account: AccountModel, call: CallModel) -> (displayName: String, handle: String)? { let type = account.type == AccountType.ring ? URIType.ring : URIType.sip - let uri = JamiURI.init(schema: type, infoHash: call.participantUri, account: account) + let uri = JamiURI.init(schema: type, infoHash: call.callUri, account: account) guard var handle = uri.hash else { return nil } // for sip contact if account and contact have different host name add contact host name if account.type == AccountType.sip { diff --git a/Ring/Ring/Services/CallsService.swift b/Ring/Ring/Services/CallsService.swift deleted file mode 100644 index 33ba474089d5bb7bdb60749357dd78e5cdf6e3f8..0000000000000000000000000000000000000000 --- a/Ring/Ring/Services/CallsService.swift +++ /dev/null @@ -1,826 +0,0 @@ -/* - * Copyright (C) 2017-2019 Savoir-faire Linux Inc. - * - * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com> - * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - */ - -import RxSwift -import RxRelay -import SwiftyBeaver -import Contacts -import os - -enum CallServiceError: Error { - case acceptCallFailed - case refuseCallFailed - case hangUpCallFailed - case holdCallFailed - case unholdCallFailed - case placeCallFailed -} - -enum ConferenceState: String { - case conferenceCreated - case conferenceDestroyed - case infoUpdated -} - -enum MediaType: String, CustomStringConvertible { - case audio = "MEDIA_TYPE_AUDIO" - case video = "MEDIA_TYPE_VIDEO" - - var description: String { - return self.rawValue - } -} - -typealias ConferenceUpdates = (conferenceID: String, state: String, calls: Set<String>) - -// swiftlint:disable type_body_length -// swiftlint:disable file_length -class CallsService: CallsAdapterDelegate, VCardSender { - private let disposeBag = DisposeBag() - private let callsAdapter: CallsAdapter - private let log = SwiftyBeaver.self - - var calls = BehaviorRelay<[String: CallModel]>(value: [String: CallModel]()) - var pendingConferences = [String: Set<String>]() - var createdConferences = Set<String>() /// set of created conferences, waiting to calls to be attached - - private let ringVCardMIMEType = "x-ring/ring.profile.vcard;" - - let currentCallsEvents = ReplaySubject<CallModel>.create(bufferSize: 1) - let newCall = BehaviorRelay<CallModel>(value: CallModel(withCallId: "", callDetails: [:], withMedia: [[:]])) - private let responseStream = PublishSubject<ServiceEvent>() - var sharedResponseStream: Observable<ServiceEvent> - private let newMessagesStream = PublishSubject<ServiceEvent>() - var newMessage: Observable<ServiceEvent> - let dbManager: DBManager - - let currentConferenceEvent: BehaviorRelay<ConferenceUpdates> = BehaviorRelay<ConferenceUpdates>(value: ConferenceUpdates("", "", Set<String>())) - let inConferenceCalls = PublishSubject<CallModel>() - - init(withCallsAdapter callsAdapter: CallsAdapter, dbManager: DBManager) { - self.callsAdapter = callsAdapter - self.responseStream.disposed(by: disposeBag) - self.sharedResponseStream = responseStream.share() - self.dbManager = dbManager - newMessage = newMessagesStream.share() - CallsAdapter.delegate = self - NotificationCenter.default.addObserver(self, selector: #selector(self.refuseUnansweredCall(_:)), - name: NSNotification.Name(rawValue: NotificationName.refuseCallFromNotifications.rawValue), - object: nil) - self.calls.asObservable() - .subscribe(onNext: { calls in - if calls.isEmpty { - NotificationCenter.default.post(name: NSNotification.Name(NotificationName.restoreDefaultVideoDevice.rawValue), object: nil, userInfo: nil) - } - }) - .disposed(by: self.disposeBag) - } - - func currentCall(callId: String) -> Observable<CallModel> { - return self.currentCallsEvents - .share() - .filter { (call) -> Bool in - call.callId == callId - } - .asObservable() - } - - @objc - func refuseUnansweredCall(_ notification: NSNotification) { - guard let callId = notification.userInfo?[Constants.NotificationUserInfoKeys.callID.rawValue] as? String else { - return - } - guard let call = self.call(callID: callId) else { - return - } - - if call.state == .incoming { - self.refuse(callId: callId) - .subscribe({_ in - print("Call ignored") - }) - .disposed(by: self.disposeBag) - } - } - - func call(callID: String) -> CallModel? { - return self.calls.value[callID] - } - - func callByUUID(UUID: String) -> CallModel? { - return self.calls.value.values.filter { call in - call.callUUID.uuidString == UUID - }.first - } - - func getVideoCodec(call: CallModel) -> String? { - let callDetails = self.callsAdapter.callDetails(withCallId: call.callId, accountId: call.accountId) - return callDetails?[CallDetailKey.videoCodec.rawValue] - } - - func call(participantHash: String, accountID: String) -> CallModel? { - return self.calls - .value.values - .filter { (callModel) -> Bool in - callModel.paricipantHash() == participantHash && - callModel.accountId == accountID - }.first - } - - func accept(call: CallModel?) -> Completable { - return Completable.create(subscribe: { completable in - guard let callId = call?.callId else { - completable(.error(CallServiceError.acceptCallFailed)) - return Disposables.create { } - } - let success = self.callsAdapter.acceptCall(withId: callId, accountId: call?.accountId, withMedia: call?.mediaList) - if success { - completable(.completed) - } else { - completable(.error(CallServiceError.acceptCallFailed)) - } - return Disposables.create { } - }) - } - - func joinConference(confID: String, callID: String) { - guard let secondConf = self.call(callID: callID) else { return } - guard let firstConf = self.call(callID: confID) else { return } - if let pending = self.pendingConferences[confID], !pending.isEmpty { - self.pendingConferences[confID]!.insert(callID) - } else { - self.pendingConferences[confID] = [callID] - } - if secondConf.participantsCallId.count == 1 { - self.callsAdapter.joinConference(confID, call: callID, accountId: firstConf.accountId, account2Id: secondConf.accountId) - } else { - self.callsAdapter.joinConferences(confID, secondConference: callID, accountId: firstConf.accountId, account2Id: secondConf.accountId) - } - } - - func joinCall(firstCallId: String, secondCallId: String) { - guard let firstCall = self.call(callID: firstCallId) else { return } - guard let secondCall = self.call(callID: secondCallId) else { return } - if let pending = self.pendingConferences[firstCallId], !pending.isEmpty { - self.pendingConferences[firstCallId]!.insert(secondCallId) - } else { - self.pendingConferences[firstCallId] = [secondCallId] - } - self.callsAdapter.joinCall(firstCallId, second: secondCallId, accountId: firstCall.accountId, account2Id: secondCall.accountId) - } - - func isParticipant(participantURI: String?, activeIn conferenceId: String, accountId: String) -> Bool? { - guard let uri = participantURI, - let participantsArray = self.callsAdapter.getConferenceInfo(conferenceId, accountId: accountId) as? [[String: String]] else { return nil } - let participants = self.arrayToConferenceParticipants(participants: participantsArray, onlyURIAndActive: true) - for participant in participants where participant.uri?.filterOutHost() == uri.filterOutHost() { - return participant.isActive - } - return nil - } - - private func arrayToConferenceParticipants(participants: [[String: String]], onlyURIAndActive: Bool) -> [ConferenceParticipant] { - var conferenceParticipants = [ConferenceParticipant]() - for participant in participants { - conferenceParticipants.append(ConferenceParticipant(info: participant, onlyURIAndActive: onlyURIAndActive)) - } - return conferenceParticipants - } - - var conferenceInfos = [String: [ConferenceParticipant]]() - - func conferenceInfoUpdated(conference conferenceID: String, info: [[String: String]]) { - let participants = self.arrayToConferenceParticipants(participants: info, onlyURIAndActive: false) - self.conferenceInfos[conferenceID] = participants - currentConferenceEvent.accept(ConferenceUpdates(conferenceID, ConferenceState.infoUpdated.rawValue, [""])) - } - - func isModerator(participantId: String, inConference confId: String) -> Bool { - let participants = self.conferenceInfos[confId] - let participant = participants?.filter({ confParticipant in - return confParticipant.uri?.filterOutHost() == participantId.filterOutHost() - }).first - return participant?.isModerator ?? false - } - - func getConferenceParticipants(for conferenceId: String) -> [ConferenceParticipant]? { - return conferenceInfos[conferenceId] - } - - func setActiveParticipant(conferenceId: String, maximixe: Bool, jamiId: String) { - guard let conference = self.call(callID: conferenceId), - let isActive = self.isParticipant(participantURI: jamiId, activeIn: conferenceId, accountId: conference.accountId) else { return } - let newLayout = isActive ? self.getNewLayoutForActiveParticipant(currentLayout: conference.layout, maximixe: maximixe) : .oneWithSmal - conference.layout = newLayout - self.callsAdapter.setActiveParticipant(jamiId, forConference: conferenceId, accountId: conference.accountId) - self.callsAdapter.setConferenceLayout(newLayout.rawValue, forConference: conferenceId, accountId: conference.accountId) - } - - private func getNewLayoutForActiveParticipant(currentLayout: CallLayout, maximixe: Bool) -> CallLayout { - var newLayout = CallLayout.grid - switch currentLayout { - case .grid: - newLayout = .oneWithSmal - case .oneWithSmal: - newLayout = maximixe ? .one : .grid - case .one: - newLayout = .oneWithSmal - } - return newLayout - } - - func callAndAddParticipant(participant contactId: String, - toCall callId: String, - withAccount account: AccountModel, - userName: String, - videSource: String, - isAudioOnly: Bool = false) -> Observable<CallModel> { - let call = self.calls.value[callId] - let placeCall = self.placeCall(withAccount: account, - toParticipantId: contactId, - userName: userName, - videoSource: videSource, - isAudioOnly: isAudioOnly, - withMedia: call?.mediaList ?? [[String: String]]()) - .asObservable() - .publish() - placeCall - .subscribe(onNext: { (callModel) in - self.inConferenceCalls.onNext(callModel) - if let pending = self.pendingConferences[callId], !pending.isEmpty { - self.pendingConferences[callId]!.insert(callModel.callId) - } else { - self.pendingConferences[callId] = [callModel.callId] - } - }) - .disposed(by: self.disposeBag) - placeCall.connect().disposed(by: self.disposeBag) - return placeCall - } - - func refuse(callId: String) -> Completable { - return Completable.create(subscribe: { completable in - guard let call = self.call(callID: callId) else { - completable(.error(CallServiceError.hangUpCallFailed)) - return Disposables.create { } - } - let success = self.callsAdapter.refuseCall(withId: callId, accountId: call.accountId) - if success { - completable(.completed) - } else { - completable(.error(CallServiceError.refuseCallFailed)) - } - return Disposables.create { } - }) - } - - func stopCall(call: CallModel) { - self.callsAdapter.hangUpCall(call.callId, accountId: call.accountId) - } - - func stopPendingCall(callId: String) { - guard let call = self.call(callID: callId) else { return } - self.stopCall(call: call) - } - func answerCall(call: CallModel) -> Bool { - NSLog("call service answerCall %@", call.callId) - return self.callsAdapter.acceptCall(withId: call.callId, accountId: call.accountId, withMedia: call.mediaList) - } - - func hangUp(callId: String) -> Completable { - return Completable.create(subscribe: { completable in - var success: Bool - guard let call = self.call(callID: callId) else { - completable(.error(CallServiceError.hangUpCallFailed)) - return Disposables.create { } - } - success = self.callsAdapter.hangUpCall(callId, accountId: call.accountId) - if success { - completable(.completed) - } else { - completable(.error(CallServiceError.hangUpCallFailed)) - } - return Disposables.create { } - }) - } - - func hangUpCallOrConference(callId: String) -> Completable { - return Completable.create(subscribe: { completable in - guard let call = self.call(callID: callId) else { - completable(.error(CallServiceError.hangUpCallFailed)) - return Disposables.create { } - } - var success: Bool - if call.participantsCallId.count < 2 { - success = self.callsAdapter.hangUpCall(callId, accountId: call.accountId) - } else { - success = self.callsAdapter.hangUpConference(callId, accountId: call.accountId) - } - if success { - completable(.completed) - } else { - completable(.error(CallServiceError.hangUpCallFailed)) - } - return Disposables.create { } - }) - } - - func hold(callId: String) -> Completable { - return Completable.create(subscribe: { completable in - guard let call = self.call(callID: callId) else { - completable(.error(CallServiceError.hangUpCallFailed)) - return Disposables.create { } - } - let success = self.callsAdapter.holdCall(withId: callId, accountId: call.accountId) - if success { - completable(.completed) - } else { - completable(.error(CallServiceError.holdCallFailed)) - } - return Disposables.create { } - }) - } - - func unhold(callId: String) -> Completable { - return Completable.create(subscribe: { completable in - guard let call = self.call(callID: callId) else { - completable(.error(CallServiceError.hangUpCallFailed)) - return Disposables.create { } - } - let success = self.callsAdapter.unholdCall(withId: callId, accountId: call.accountId) - if success { - completable(.completed) - } else { - completable(.error(CallServiceError.unholdCallFailed)) - } - return Disposables.create { } - }) - } - - func placeCall(withAccount account: AccountModel, - toParticipantId participantId: String, - userName: String, - videoSource: String, - isAudioOnly: Bool = false, - withMedia: [[String: String]] = [[String: String]]()) -> Single<CallModel> { - - // Create and emit the call - var callDetails = [String: String]() - callDetails[CallDetailKey.callTypeKey.rawValue] = String(describing: CallType.outgoing) - callDetails[CallDetailKey.displayNameKey.rawValue] = userName - callDetails[CallDetailKey.accountIdKey.rawValue] = account.id - callDetails[CallDetailKey.audioOnlyKey.rawValue] = isAudioOnly.toString() - callDetails[CallDetailKey.timeStampStartKey.rawValue] = "" - - var mediaList = withMedia - if mediaList.isEmpty { - var mediaAttribute = [String: String]() - mediaAttribute[MediaAttributeKey.mediaType.rawValue] = MediaAttributeValue.audio.rawValue - mediaAttribute[MediaAttributeKey.label.rawValue] = "audio_0" - mediaAttribute[MediaAttributeKey.enabled.rawValue] = "true" - mediaAttribute[MediaAttributeKey.muted.rawValue] = "false" - mediaList.append(mediaAttribute) - if !isAudioOnly { - mediaAttribute[MediaAttributeKey.mediaType.rawValue] = MediaAttributeValue.video.rawValue - mediaAttribute[MediaAttributeKey.label.rawValue] = "video_0" - mediaAttribute[MediaAttributeKey.source.rawValue] = videoSource - mediaList.append(mediaAttribute) - } - } - - let call = CallModel(withCallId: participantId, callDetails: callDetails, withMedia: mediaList) - call.state = .unknown - call.callType = .outgoing - call.participantUri = participantId - return Single<CallModel>.create(subscribe: { [weak self] single in - if let self = self, let callId = self.callsAdapter.placeCall(withAccountId: account.id, - toParticipantId: participantId, - withMedia: mediaList), !callId.isEmpty, - let callDictionary = self.callsAdapter.callDetails(withCallId: callId, accountId: account.id) { - call.update(withDictionary: callDictionary, withMedia: mediaList) - call.participantUri = participantId - call.callId = callId - call.participantsCallId.removeAll() - call.participantsCallId.insert(callId) - self.currentCallsEvents.onNext(call) - var values = self.calls.value - values[callId] = call - self.calls.accept(values) - single(.success(call)) - } else { - single(.failure(CallServiceError.placeCallFailed)) - } - return Disposables.create { } - }) - } - - func playDTMF(code: String) { - self.callsAdapter.playDTMF(code) - } - - func sendVCard(callID: String, accountID: String) { - if accountID.isEmpty || callID.isEmpty { - return - } - DispatchQueue.global(qos: .background).async { [weak self] in - guard let self = self else { return } - guard let profile = self.dbManager.accountVCard(for: accountID) else { return } - let jamiId = profile.uri - VCardUtils.sendVCard(card: profile, - callID: callID, - accountID: accountID, - sender: self, from: jamiId) - } - } - - func sendTextMessage(callID: String, message: String, accountId: AccountModel) { - guard let call = self.call(callID: callID) else { return } - let messageDictionary = ["text/plain": message] - self.callsAdapter.sendTextMessage(withCallID: callID, - accountId: accountId.id, - message: messageDictionary, - from: call.paricipantHash(), - isMixed: true) - let accountHelper = AccountModelHelper(withAccount: accountId) - let type = accountHelper.isAccountSip() ? URIType.sip : URIType.ring - let contactUri = JamiURI.init(schema: type, infoHash: call.participantUri, account: accountId) - guard let stringUri = contactUri.uriString else { - return - } - if let uri = accountHelper.uri { - var event = ServiceEvent(withEventType: .newOutgoingMessage) - event.addEventInput(.content, value: message) - event.addEventInput(.peerUri, value: stringUri) - event.addEventInput(.accountId, value: accountId.id) - event.addEventInput(.accountUri, value: uri) - - self.newMessagesStream.onNext(event) - } - } - - func sendChunk(callID: String, message: [String: String], accountId: String, from: String) { - self.callsAdapter.sendTextMessage(withCallID: callID, - accountId: accountId, - message: message, - from: from, - isMixed: true) - } - - func updateCallUUID(callId: String, callUUID: String) { - if let call = self.call(callID: callId), let uuid = UUID(uuidString: callUUID) { - call.callUUID = uuid - } - } - - // MARK: CallsAdapterDelegate - // swiftlint:disable cyclomatic_complexity - func didChangeCallState(withCallId callId: String, state: String, accountId: String, stateCode: NSInteger) { - if let callDictionary = self.callsAdapter.callDetails(withCallId: callId, accountId: accountId) { - // Add or update new call - var call = self.calls.value[callId] - var callState = CallState(rawValue: state) ?? CallState.unknown - call?.state = callState - // Remove from the cache if the call is over and save message to history - if call?.state == .over || call?.state == .failure { - guard let finishedCall = call else { return } - var time = 0 - if let startTime = finishedCall.dateReceived { - time = Int(Date().timeIntervalSince1970 - startTime.timeIntervalSince1970) - } - var event = ServiceEvent(withEventType: .callEnded) - event.addEventInput(.peerUri, value: finishedCall.participantUri) - event.addEventInput(.callUUID, value: finishedCall.callUUID.uuidString) - event.addEventInput(.accountId, value: finishedCall.accountId) - event.addEventInput(.callType, value: finishedCall.callType.rawValue) - event.addEventInput(.callTime, value: time) - self.responseStream.onNext(event) - self.currentCallsEvents.onNext(finishedCall) - var values = self.calls.value - values[callId] = nil - self.calls.accept(values) - // clear pending conferences if need - if self.pendingConferences.keys.contains(callId) { - self.pendingConferences[callId] = nil - } - if let confId = shouldCallBeAddedToConference(callId: callId), - var pendingCalls = self.pendingConferences[confId], - let index = pendingCalls.firstIndex(of: callId) { - pendingCalls.remove(at: index) - if pendingCalls.isEmpty { - self.pendingConferences[confId] = nil - } else { - self.pendingConferences[confId] = pendingCalls - } - } - self.updateConferences(callId: callId) - return - } - let mediaList = [[String: String]]() - if call == nil { - if !callState.isActive() { - return - } - call = CallModel(withCallId: callId, callDetails: callDictionary, withMedia: mediaList) - var values = self.calls.value - values[callId] = call - self.calls.accept(values) - } else { - call?.update(withDictionary: callDictionary, withMedia: mediaList) - } - guard let newCall = call else { return } - // send vCard - if (newCall.state == .ringing && newCall.callType == .outgoing) || - (newCall.state == .current && newCall.callType == .incoming) { - self.sendVCard(callID: callId, accountID: newCall.accountId) - } - - if newCall.state == .current { - if let confId = shouldCallBeAddedToConference(callId: callId) { - let seconds = 1.0 - if let pendingCall = self.call(callID: confId) { - DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { - if pendingCall.participantsCallId.count == 1 { - self.callsAdapter.joinCall(confId, second: callId, accountId: pendingCall.accountId, account2Id: accountId) - } else { - self.callsAdapter.joinConference(confId, call: callId, accountId: pendingCall.accountId, account2Id: accountId) - } - } - } - } - } - - // Emit the call to the observers - self.currentCallsEvents.onNext(newCall) - } - } - - func didChangeMediaNegotiationStatus(withCallId callId: String, event: String, withMedia: [[String: String]]) { - guard let call = self.calls.value[callId], - let callDictionary = self.callsAdapter.callDetails(withCallId: callId, accountId: call.accountId) else { return } - call.update(withDictionary: callDictionary, withMedia: withMedia) - self.currentCallsEvents.onNext(call) - } - - func didReceiveMediaChangeRequest(withAccountId accountId: String, callId: String, withMedia: [[String: String]]) { - guard let call = self.calls.value[callId] else { return } - var currentMediaLabels = [String]() - for media in call.mediaList where media[MediaAttributeKey.label.rawValue] != nil { - currentMediaLabels.append(media[MediaAttributeKey.label.rawValue]!) - } - - var answerMedias = [[String: String]]() - for media in withMedia { - let label = media[MediaAttributeKey.label.rawValue] ?? "" - let index = currentMediaLabels.firstIndex(of: label ) ?? -1 - if index >= 0 { - var answerMedia = media - answerMedia[MediaAttributeKey.muted.rawValue] = call.mediaList[index][MediaAttributeKey.muted.rawValue] - answerMedia[MediaAttributeKey.enabled.rawValue] = call.mediaList[index][MediaAttributeKey.enabled.rawValue] - answerMedias.append(answerMedia) - } else { - var answerMedia = media - answerMedia[MediaAttributeKey.muted.rawValue] = "true" - answerMedia[MediaAttributeKey.enabled.rawValue] = "true" - answerMedias.append(answerMedia) - } - } - self.callsAdapter.answerMediaChangeResquest(callId, accountId: accountId, withMedia: answerMedias) - } - - func shouldCallBeAddedToConference(callId: String) -> String? { - var confId: String? - self.pendingConferences.keys.forEach { [weak self] (initialCall) in - guard let self = self, let pendigs = self.pendingConferences[initialCall], !pendigs.isEmpty - else { return } - if pendigs.contains(callId) { - confId = initialCall - } - } - return confId - } - - func didReceiveMessage(withCallId callId: String, fromURI uri: String, message: [String: String]) { - guard let call = self.call(callID: callId) else { return } - if message.keys.filter({ $0.hasPrefix(self.ringVCardMIMEType) }).first != nil { - var data = [String: Any]() - data[ProfileNotificationsKeys.ringID.rawValue] = uri - data[ProfileNotificationsKeys.accountId.rawValue] = call.accountId - data[ProfileNotificationsKeys.message.rawValue] = message - NotificationCenter.default.post(name: NSNotification.Name(ProfileNotifications.messageReceived.rawValue), object: nil, userInfo: data) - return - } - let accountId = call.accountId - let displayName = call.displayName - let registeredName = call.registeredName - let name = !displayName.isEmpty ? displayName : registeredName - var event = ServiceEvent(withEventType: .newIncomingMessage) - event.addEventInput(.content, value: message.values.first) - event.addEventInput(.peerUri, value: uri.filterOutHost()) - event.addEventInput(.name, value: name) - event.addEventInput(.accountId, value: accountId) - self.newMessagesStream.onNext(event) - } - // swiftlint:enable cyclomatic_complexity - - func receivingCall(withAccountId accountId: String, callId: String, fromURI uri: String, withMedia mediaList: [[String: String]]) { - os_log("incoming call call service") - if let callDictionary = self.callsAdapter.callDetails(withCallId: callId, accountId: accountId) { - var call = self.calls.value[callId] - if call == nil { - call = CallModel(withCallId: callId, callDetails: callDictionary, withMedia: mediaList) - } else { - call?.update(withDictionary: callDictionary, withMedia: mediaList) - } - // Emit the call to the observers - guard let newCall = call else { return } - self.newCall.accept(newCall) - } - } - - func isCurrentCall() -> Bool { - for call in self.calls.value.values { - if call.state == .current || call.state == .hold || - call.state == .unhold || call.state == .ringing { - return true - } - } - return false - } - - func callPlacedOnHold(withCallId callId: String, holding: Bool) { - guard let call = self.calls.value[callId] else { - return - } - call.peerHolding = holding - self.currentCallsEvents.onNext(call) - } - - func audioMuted(call callId: String, mute: Bool) { - guard let call = self.calls.value[callId] else { - return - } - call.audioMuted = mute - self.currentCallsEvents.onNext(call) - } - func remoteRecordingChanged(call callId: String, record: Bool) { - guard let call = self.calls.value[callId] else { - return - } - call.callRecorded = record - self.currentCallsEvents.onNext(call) - } - - func videoMuted(call callId: String, mute: Bool) { - guard let call = self.calls.value[callId] else { - return - } - call.videoMuted = mute - self.currentCallsEvents.onNext(call) - } - - func callMediaUpdated(call: CallModel) { - var mediaList = call.mediaList - if mediaList.isEmpty { - guard let attributes = self.callsAdapter.currentMediaList(withCallId: call.callId, accountId: call.accountId) else { return } - call.update(withDictionary: [:], withMedia: attributes) - mediaList = call.mediaList - } - if let callDictionary = self.callsAdapter.callDetails(withCallId: call.callId, accountId: call.accountId) { - call.update(withDictionary: callDictionary, withMedia: mediaList) - self.currentCallsEvents.onNext(call) - } - } - - func updateCallMediaIfNeeded(call: CallModel) { - var mediaList = call.mediaList - if mediaList.isEmpty { - guard let attributes = self.callsAdapter.currentMediaList(withCallId: call.callId, accountId: call.accountId) else { return } - call.update(withDictionary: [:], withMedia: attributes) - mediaList = call.mediaList - } - call.mediaList = mediaList - } - - func conferenceCreated(conference conferenceID: String, accountId: String) { - let conferenceCalls = Set(self.callsAdapter - .getConferenceCalls(conferenceID, accountId: accountId)) - if conferenceCalls.isEmpty { - // no calls attached to a conference. Wait until conference changed to check the calls. - createdConferences.insert(conferenceID) - return - } - createdConferences.remove(conferenceID) - self.pendingConferences.forEach { pending in - if !conferenceCalls.contains(pending.key) || - conferenceCalls.isDisjoint(with: pending.value) { - return - } - let callId = pending.key - var values = pending.value - // update pending conferences - // replace callID by new Conference ID, and remove calls that was already added to onference - values.subtract(conferenceCalls) - self.pendingConferences[callId] = nil - if !values.isEmpty { - self.pendingConferences[conferenceID] = values - } - // update calls and add conference - self.call(callID: callId)?.participantsCallId = conferenceCalls - values.forEach { (call) in - self.call(callID: call)?.participantsCallId = conferenceCalls - } - guard var callDetails = self.callsAdapter.getConferenceDetails(conferenceID, accountId: accountId) else { return } - callDetails[CallDetailKey.accountIdKey.rawValue] = self.call(callID: callId)?.accountId - callDetails[CallDetailKey.audioOnlyKey.rawValue] = self.call(callID: callId)?.isAudioOnly.toString() - let mediaList = [[String: String]]() - let conf = CallModel(withCallId: conferenceID, callDetails: callDetails, withMedia: mediaList) - conf.participantsCallId = conferenceCalls - var value = self.calls.value - value[conferenceID] = conf - self.calls.accept(value) - currentConferenceEvent.accept(ConferenceUpdates(conferenceID, ConferenceState.conferenceCreated.rawValue, conferenceCalls)) - } - } - - func conferenceChanged(conference conferenceID: String, accountId: String, state: String) { - if createdConferences.contains(conferenceID) { - // a conference was created but calls was not attached to a conference. In this case a conference should be added first. - self.conferenceCreated(conference: conferenceID, accountId: accountId) - return - } - guard let conference = self.call(callID: conferenceID) else { return } - let conferenceCalls = Set(self.callsAdapter - .getConferenceCalls(conferenceID, accountId: conference.accountId)) - conference.participantsCallId = conferenceCalls - conferenceCalls.forEach { (callId) in - guard let call = self.call(callID: callId) else { return } - call.participantsCallId = conferenceCalls - var values = self.calls.value - values[callId] = call - self.calls.accept(values) - } - } - - func conferenceRemoved(conference conferenceID: String) { - guard let conference = self.call(callID: conferenceID) else { return } - self.conferenceInfos[conferenceID] = nil - self.currentConferenceEvent.accept(ConferenceUpdates(conferenceID, ConferenceState.infoUpdated.rawValue, [""])) - self.currentConferenceEvent.accept(ConferenceUpdates(conferenceID, ConferenceState.conferenceDestroyed.rawValue, conference.participantsCallId)) - var values = self.calls.value - values[conferenceID] = nil - self.calls.accept(values) - } - - func updateConferences(callId: String) { - let conferences = self.calls.value.keys.filter { (callID) -> Bool in - guard let callModel = self.calls.value[callID] else { return false } - return callModel.participantsCallId.count > 1 && callModel.participantsCallId.contains(callId) - } - - guard let conferenceID = conferences.first, let conference = call(callID: conferenceID) else { return } - let conferenceCalls = Set(self.callsAdapter - .getConferenceCalls(conferenceID, accountId: conference.accountId)) - conference.participantsCallId = conferenceCalls - conferenceCalls.forEach { (callID) in - self.call(callID: callID)?.participantsCallId = conferenceCalls - } - } - - func setModeratorParticipant(confId: String, participantId: String, active: Bool) { - guard let conference = call(callID: confId) else { return } - self.callsAdapter.setConferenceModerator(participantId, forConference: confId, accountId: conference.accountId, active: active) - } - - func hangupParticipant(confId: String, participantId: String, device: String) { - guard let conference = call(callID: confId) else { return } - self.callsAdapter.hangupConferenceParticipant(participantId, forConference: confId, accountId: conference.accountId, deviceId: device) - } - - func muteStream(confId: String, participantId: String, device: String, accountId: String, streamId: String, state: Bool) { - self.callsAdapter.muteStream(participantId, forConference: confId, accountId: accountId, deviceId: device, streamId: streamId, state: state) - } - - func setRaiseHand(confId: String, participantId: String, state: Bool, accountId: String, deviceId: String) { - guard let conference = call(callID: confId) else { return } - self.callsAdapter.raiseHand(participantId, forConference: confId, accountId: accountId, deviceId: deviceId, state: state) - } - -} diff --git a/Ring/Ring/Services/ConversationsManager.swift b/Ring/Ring/Services/ConversationsManager.swift index 9d77b1bdade3579aebde247b0b8ca895345de6bf..5f72b5fde6dda5991ca9cba947859375d73088d9 100644 --- a/Ring/Ring/Services/ConversationsManager.swift +++ b/Ring/Ring/Services/ConversationsManager.swift @@ -662,6 +662,13 @@ extension ConversationsManager: MessagesAdapterDelegate { self.requestService.conversationRemoved(conversationId: conversationId, accountId: accountId) self.conversationService.conversationRemoved(conversationId: conversationId, accountId: accountId) } + + func activeCallsChanged(conversationId: String, accountId: String, calls: [[String: String]]) { + guard let account = self.accountsService.getAccount(fromAccountId: accountId) else { + return + } + self.callService.activeCallsChanged(conversationId: conversationId, accountId: accountId, calls: calls, account: account) + } } extension ConversationsManager: RequestsAdapterDelegate { diff --git a/Ring/Ring/Services/MessagesAdapterDelegate.swift b/Ring/Ring/Services/MessagesAdapterDelegate.swift index f3361761254957949003640949924ea3fe55b0c4..f72b02d5dec3b173bb0bad0fa248a053a6233c1c 100644 --- a/Ring/Ring/Services/MessagesAdapterDelegate.swift +++ b/Ring/Ring/Services/MessagesAdapterDelegate.swift @@ -39,4 +39,5 @@ func composingStatusChanged(accountId: String, conversationId: String, from: String, status: Int) func reactionRemoved(conversationId: String, accountId: String, messageId: String, reactionId: String) func messageUpdated(conversationId: String, accountId: String, message: SwarmMessageWrap) + func activeCallsChanged(conversationId: String, accountId: String, calls: [[String: String]]) } diff --git a/Ring/Ring/Services/RequestsService.swift b/Ring/Ring/Services/RequestsService.swift index d0d0e51185b5051d44490b415b362c23de39cdb8..e497dd9b64a9a3fe5b70910c04d62900ff1b59f9 100644 --- a/Ring/Ring/Services/RequestsService.swift +++ b/Ring/Ring/Services/RequestsService.swift @@ -295,7 +295,7 @@ class RequestsService { do { var payload: Data? if let accountProfile = self.dbManager.accountProfile(for: accountId) { - var cardChanged = accountProfile.alias != nil || accountProfile.photo != nil + let cardChanged = accountProfile.alias != nil || accountProfile.photo != nil if cardChanged { payload = try VCardUtils.dataWithImageAndUUID(from: accountProfile) } diff --git a/Ring/Ring/Services/ServiceEvent.swift b/Ring/Ring/Services/ServiceEvent.swift index a220896389a5cc125263634cdb8e2b96f341539a..06863c100ca668278e593218a5e2780d17914aee 100644 --- a/Ring/Ring/Services/ServiceEvent.swift +++ b/Ring/Ring/Services/ServiceEvent.swift @@ -63,6 +63,8 @@ enum ServiceEventType { case requestAccepted case addDeviceStateChanged case deviceAuthStateChanged + case callStarted + case incomingCall } /** @@ -81,6 +83,9 @@ enum ServiceEventInput { case date case callType case callTime + case callState + case mediaType + case mediaState case transferId case conversationId case localPhotolID diff --git a/Ring/Ring/Services/VideoManager.swift b/Ring/Ring/Services/VideoManager.swift index 2256f665263c893eae72997e7af307d52459e577..4c9bae328d96cf9e197181588efbb00b32b412fc 100644 --- a/Ring/Ring/Services/VideoManager.swift +++ b/Ring/Ring/Services/VideoManager.swift @@ -44,7 +44,7 @@ class VideoManager { } .subscribe { [weak self] _ in guard let self = self else { return } - let calls = self.callService.calls.value + let calls = self.callService.calls.get() guard calls.count <= 1 else { return } self.videoService.stopCapture(withDevice: "camera://") self.videoService.setCameraOrientation(orientation: UIDevice.current.orientation) diff --git a/Ring/RingTests/ActiveCallsHelperTests.swift b/Ring/RingTests/ActiveCallsHelperTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..8a66419309b6944f2f29db2b3d60cc85a8c548ca --- /dev/null +++ b/Ring/RingTests/ActiveCallsHelperTests.swift @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import XCTest +@testable import Ring + +final class ActiveCallsHelperTests: XCTestCase { + private var activeCallsHelper: ActiveCallsHelper! + + override func setUp() { + super.setUp() + activeCallsHelper = ActiveCallsHelper() + } + + override func tearDown() { + activeCallsHelper = nil + super.tearDown() + } + + func testActiveCallsChanged_WithValidCalls_UpdatesCallsCorrectly() { + let accountId = "account1" + let conversationId = "conv1" + let calls = [ + ["id": "call1", "uri": "uri1", "device": "device1"], + ["id": "call2", "uri": "uri2", "device": "device2"] + ] + let account = AccountModel(withAccountId: accountId) + + activeCallsHelper.updateActiveCalls(conversationId: conversationId, calls: calls, account: account) + + let result = activeCallsHelper.activeCalls.value + XCTAssertNotNil(result[accountId]) + let accountCalls = result[accountId]! + let conversationCalls = accountCalls.calls(for: conversationId) + XCTAssertEqual(conversationCalls.count, 2) + XCTAssertEqual(conversationCalls[0].id, "call1") + XCTAssertEqual(conversationCalls[1].id, "call2") + } + + func testActiveCallsChanged_WithEmptyCalls_ClearsIgnoredCalls() { + let accountId = "account1" + let conversationId = "conv1" + let account = AccountModel(withAccountId: accountId) + let call = ActiveCall(id: "call1", uri: "uri1", device: "device1", conversationId: conversationId, accountId: accountId, isFromLocalDevice: false) + + activeCallsHelper.ignoreCall(call) + activeCallsHelper.updateActiveCalls(conversationId: conversationId, calls: [], account: account) + + let result = activeCallsHelper.activeCalls.value + let accountCalls = result[accountId]! + let ignoredCalls = accountCalls.ignoredCalls(for: conversationId) + XCTAssertTrue(ignoredCalls.isEmpty) + } + + func testIgnoreCall_AddsCallToIgnoredCalls() { + let accountId = "account1" + let conversationId = "conv1" + let call = ActiveCall(id: "call1", uri: "uri1", device: "device1", conversationId: conversationId, accountId: accountId, isFromLocalDevice: false) + + activeCallsHelper.ignoreCall(call) + + let result = activeCallsHelper.activeCalls.value + let accountCalls = result[accountId]! + let ignoredCalls = accountCalls.ignoredCalls(for: conversationId) + XCTAssertEqual(ignoredCalls.count, 1) + let callId: String = ignoredCalls.first!.id + XCTAssertEqual(callId, "call1") + } + + func testActiveCallsChanged_WithInvalidCallData_IgnoresInvalidCalls() { + let accountId = "account1" + let conversationId = "conv1" + let calls = [ + ["id": "call1"], // Missing uri and device + ["id": "call2", "uri": "uri2", "device": "device2"] + ] + + let account = AccountModel(withAccountId: accountId) + + activeCallsHelper.updateActiveCalls(conversationId: conversationId, calls: calls, account: account) + + let result = activeCallsHelper.activeCalls.value + let accountCalls = result[accountId]! + let conversationCalls = accountCalls.calls(for: conversationId) + XCTAssertEqual(conversationCalls.count, 1) + XCTAssertEqual(conversationCalls[0].id, "call2") + } + + func testActiveCallsChanged_UpdatesMultipleConversations() { + let accountId = "account1" + let conversationId1 = "conv1" + let conversationId2 = "conv2" + + let account = AccountModel(withAccountId: accountId) + + activeCallsHelper.updateActiveCalls(conversationId: conversationId1, calls: [["id": "call1", "uri": "uri1", "device": "device1"]], account: account) + activeCallsHelper.updateActiveCalls(conversationId: conversationId2, calls: [["id": "call2", "uri": "uri2", "device": "device2"]], account: account) + + let result = activeCallsHelper.activeCalls.value + let accountCalls = result[accountId]! + let calls1 = accountCalls.calls(for: conversationId1) + let calls2 = accountCalls.calls(for: conversationId2) + XCTAssertEqual(calls1.count, 1) + XCTAssertEqual(calls2.count, 1) + XCTAssertEqual(calls1[0].id, "call1") + XCTAssertEqual(calls2[0].id, "call2") + } + + func testActiveCallsChanged_UpdatesMultipleAccounts() { + let accountId1 = "account1" + let accountId2 = "account2" + let conversationId = "conv1" + + let account1 = AccountModel(withAccountId: accountId1) + let account2 = AccountModel(withAccountId: accountId2) + + activeCallsHelper.updateActiveCalls(conversationId: conversationId, calls: [["id": "call1", "uri": "uri1", "device": "device1"]], account: account1) + activeCallsHelper.updateActiveCalls(conversationId: conversationId, calls: [["id": "call2", "uri": "uri2", "device": "device2"]], account: account2) + + let result = activeCallsHelper.activeCalls.value + let accountCalls1 = result[accountId1]! + let accountCalls2 = result[accountId2]! + let calls1 = accountCalls1.calls(for: conversationId) + let calls2 = accountCalls2.calls(for: conversationId) + XCTAssertEqual(calls1.count, 1) + XCTAssertEqual(calls2.count, 1) + XCTAssertEqual(calls1[0].id, "call1") + XCTAssertEqual(calls2[0].id, "call2") + } +} diff --git a/Ring/RingTests/CallManagementServiceTests.swift b/Ring/RingTests/CallManagementServiceTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..cea76eb3189405cd7d139338cbd980a5406bff30 --- /dev/null +++ b/Ring/RingTests/CallManagementServiceTests.swift @@ -0,0 +1,382 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import XCTest +import RxSwift +import RxRelay +@testable import Ring + +class CallManagementServiceTests: XCTestCase { + + private var callManagementService: CallManagementService! + private var mockCallsAdapter: ObjCMockCallsAdapter! + private var calls: SynchronizedRelay<CallsDictionary>! + private var callUpdates: ReplaySubject<CallModel>! + private var responseStream: PublishSubject<ServiceEvent>! + private var queueHelper: ThreadSafeQueueHelper! + private var disposeBag: DisposeBag! + private var testCall: CallModel! + + override func setUp() { + super.setUp() + setupMocks() + setupService() + } + + override func tearDown() { + disposeBag = nil + callManagementService = nil + mockCallsAdapter = nil + calls = nil + callUpdates = nil + responseStream = nil + queueHelper = nil + testCall = nil + super.tearDown() + } + + private func setupMocks() { + mockCallsAdapter = ObjCMockCallsAdapter() + callUpdates = ReplaySubject<CallModel>.create(bufferSize: 1) + responseStream = PublishSubject<ServiceEvent>() + queueHelper = ThreadSafeQueueHelper(label: "com.ring.callsManagementTest", qos: .userInitiated) + calls = SynchronizedRelay<CallsDictionary>(initialValue: [:], queueHelper: queueHelper) + disposeBag = DisposeBag() + } + + private func setupService() { + callManagementService = CallManagementService( + callsAdapter: mockCallsAdapter, + calls: calls, + callUpdates: callUpdates, + responseStream: responseStream + ) + } + + // MARK: - Tests + + func testAddCall() { + let callId = CallTestConstants.callId + let callState = CallState.ringing + let callDictionary = [ + CallDetailKey.displayNameKey.rawValue: CallTestConstants.displayName, + CallDetailKey.accountIdKey.rawValue: CallTestConstants.accountId + ] + + let mediaList = [TestMediaFactory.createAudioMedia()] + + let callUpdatesExpectation = XCTestExpectation(description: "Call updates emission") + + callUpdates + .take(1) + .subscribe(onNext: { _ in + callUpdatesExpectation.fulfill() + }) + .disposed(by: disposeBag) + + let result = callManagementService.addOrUpdateCall( + callId: callId, + callState: callState, + callDictionary: callDictionary, + mediaList: mediaList + ) + + wait(for: [callUpdatesExpectation], timeout: 1.0) + + XCTAssertNotNil(result, "Call should be returned") + XCTAssertEqual(result?.callId, callId, "Call ID should match") + XCTAssertEqual(result?.state, callState, "Call state should match") + XCTAssertEqual(result?.displayName, CallTestConstants.displayName, "Display name should match") + XCTAssertEqual(result?.mediaList.count, mediaList.count, "Media list should match") + + XCTAssertEqual(calls.get().count, 1, "Call should be added to store") + XCTAssertNotNil(calls.get()[callId], "Call should be in store with correct ID") + } + + func testUpdateCall() { + let callId = CallTestConstants.callId + let initialState = CallState.ringing + let updatedState = CallState.current + + let initialCall = CallModel.createTestCall() + initialCall.state = initialState + + calls.update { calls in + calls[callId] = initialCall + } + + let updatedCallDictionary = [ + CallDetailKey.displayNameKey.rawValue: "Updated Name", + CallDetailKey.accountIdKey.rawValue: CallTestConstants.accountId + ] + + let callUpdatesExpectation = XCTestExpectation(description: "Call updates emission") + + callUpdates + .take(1) + .subscribe(onNext: { _ in + callUpdatesExpectation.fulfill() + }) + .disposed(by: disposeBag) + + let result = callManagementService.addOrUpdateCall( + callId: callId, + callState: updatedState, + callDictionary: updatedCallDictionary + ) + + wait(for: [callUpdatesExpectation], timeout: 1.0) + + XCTAssertNotNil(result, "Updated call should be returned") + XCTAssertEqual(result?.state, updatedState, "Call state should be updated") + XCTAssertEqual(result?.displayName, "Updated Name", "Call details should be updated") + } + + func testRemoveCall() async { + let call = CallModel.createTestCall() + call.state = .current + call.dateReceived = Date(timeIntervalSinceNow: -60) // Call started 1 minute ago + + calls.update { calls in + calls[CallTestConstants.callId] = call + } + + var capturedEvent: ServiceEvent? + responseStream + .take(1) + .subscribe(onNext: { event in + capturedEvent = event + }) + .disposed(by: disposeBag) + + await callManagementService.removeCall(callId: CallTestConstants.callId, callState: .over) + + XCTAssertEqual(calls.get().count, 0, "Call should be removed from store") + XCTAssertNotNil(capturedEvent, "Event should be emitted") + XCTAssertEqual(capturedEvent!.eventType, .callEnded, "Event type should be callEnded") + } + + func testRemoveCall_WithInvalidCallId() async { + let call = CallModel.createTestCall() + call.state = .current + call.dateReceived = Date(timeIntervalSinceNow: -60) // Call started 1 minute ago + + calls.update { calls in + calls[CallTestConstants.callId] = call + } + await callManagementService.removeCall(callId: "invalid-id", callState: .over) + XCTAssertEqual(calls.get().count, 1, "Call store should remain 1") + } + + func testUpdateCallUUID_WithValidData() async { + let call = CallModel.createTestCall() + let originalUUID = call.callUUID + + calls.update { calls in + calls[CallTestConstants.callId] = call + } + + let newUUIDString = UUID().uuidString + + await callManagementService.updateCallUUID(callId: CallTestConstants.callId, callUUID: newUUIDString) + + let updatedCall = calls.get()[CallTestConstants.callId] + XCTAssertNotNil(updatedCall, "Call should still exist in store") + XCTAssertNotEqual(updatedCall?.callUUID, originalUUID, "UUID should have changed") + XCTAssertEqual(updatedCall?.callUUID.uuidString, newUUIDString, "UUID should match new value") + } + + func testAccept() { + let call = CallModel.createTestCall() + call.mediaList = [TestMediaFactory.createAudioMedia()] + + calls.update { calls in + calls[CallTestConstants.callId] = call + } + + mockCallsAdapter.acceptCallReturnValue = true + + let expectation = XCTestExpectation(description: "Accept call completes") + + callManagementService.accept(callId: CallTestConstants.callId) + .subscribe( + onCompleted: { + expectation.fulfill() + }, + onError: { error in + XCTFail("Accept call should not fail: \(error)") + } + ) + .disposed(by: disposeBag) + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(mockCallsAdapter.acceptCallIdCount, 1, "Accept call should be called once") + XCTAssertEqual(mockCallsAdapter.acceptCallIdCallId, CallTestConstants.callId, "Call ID should match") + XCTAssertEqual(mockCallsAdapter.acceptCallIdAccountId, CallTestConstants.accountId, "Account ID should match") + XCTAssertEqual(mockCallsAdapter.acceptCallIdMediaList?.count, 1, "Media list should match") + } + + func testRefuse() { + let call = CallModel.createTestCall() + + calls.update { calls in + calls[CallTestConstants.callId] = call + } + + mockCallsAdapter.refuseCallReturnValue = true + + let expectation = XCTestExpectation(description: "Refuse call completes") + + callManagementService.refuse(callId: CallTestConstants.callId) + .subscribe( + onCompleted: { + expectation.fulfill() + }, + onError: { error in + XCTFail("Refuse call should not fail: \(error)") + } + ) + .disposed(by: disposeBag) + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(mockCallsAdapter.refuseCallIdCount, 1, "Refuse call should be called once") + XCTAssertEqual(mockCallsAdapter.refuseCallIdCallId, CallTestConstants.callId, "Call ID should match") + XCTAssertEqual(mockCallsAdapter.refuseCallIdAccountId, CallTestConstants.accountId, "Account ID should match") + } + + func testHangUp() { + let call = CallModel.createTestCall() + + calls.update { calls in + calls[CallTestConstants.callId] = call + } + + mockCallsAdapter.hangUpCallReturnValue = true + + let expectation = XCTestExpectation(description: "Hang up call completes") + + callManagementService.hangUp(callId: CallTestConstants.callId) + .subscribe( + onCompleted: { + expectation.fulfill() + }, + onError: { error in + XCTFail("Hang up call should not fail: \(error)") + } + ) + .disposed(by: disposeBag) + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(mockCallsAdapter.hangUpCallCallCount, 1, "Hang up call should be called once") + XCTAssertEqual(mockCallsAdapter.hangUpCallCallId, CallTestConstants.callId, "Call ID should match") + XCTAssertEqual(mockCallsAdapter.hangUpCallAccountId, CallTestConstants.accountId, "Account ID should match") + } + + func testPlaceCall_Success() { + let account = AccountModel.createTestAccount() + let participantId = CallTestConstants.participantUri + let userName = CallTestConstants.displayName + let videoSource = "camera" + + mockCallsAdapter.placeCallReturnValue = CallTestConstants.callId + mockCallsAdapter.callDetailsReturnValue = [ + CallDetailKey.displayNameKey.rawValue: userName, + CallDetailKey.accountIdKey.rawValue: account.id + ] + + let expectation = XCTestExpectation(description: "Place call completes") + + var resultCall: CallModel? + + callManagementService.placeCall( + withAccount: account, + toParticipantId: participantId, + userName: userName, + videoSource: videoSource, + isAudioOnly: false, + withMedia: [] + ) + .subscribe( + onSuccess: { call in + resultCall = call + expectation.fulfill() + }, + onFailure: { error in + XCTFail("Place call should not fail: \(error)") + } + ) + .disposed(by: disposeBag) + + wait(for: [expectation], timeout: 1.0) + + XCTAssertNotNil(resultCall, "Call model should be returned") + XCTAssertEqual(resultCall?.callId, CallTestConstants.callId, "Call ID should match") + XCTAssertEqual(resultCall?.displayName, userName, "Display name should match") + XCTAssertEqual(resultCall?.accountId, account.id, "Account ID should match") + XCTAssertEqual(resultCall?.callType, .outgoing, "Call type should be outgoing") + + XCTAssertEqual(mockCallsAdapter.placeCallAccountIdCount, 1, "Place call should be called once") + XCTAssertEqual(mockCallsAdapter.placeCallAccountId, account.id, "Account ID should match") + XCTAssertEqual(mockCallsAdapter.placeCallParticipantId, participantId, "Participant ID should match") + } +} + +extension ObjCMockCallsAdapter { + var acceptCallIdCount: Int { + return Int(self.acceptCallWithIdCount) + } + + var acceptCallIdCallId: String? { + return self.acceptCallWithIdCallId + } + + var acceptCallIdAccountId: String? { + return self.acceptCallWithIdAccountId + } + + var acceptCallIdMediaList: [[String: String]]? { + return self.acceptCallWithIdMediaList as? [[String: String]] + } + + var refuseCallIdCount: Int { + return Int(self.refuseCallWithIdCount) + } + + var refuseCallIdCallId: String? { + return self.refuseCallWithIdCallId + } + + var refuseCallIdAccountId: String? { + return self.refuseCallWithIdAccountId + } + + var placeCallAccountIdCount: Int { + return Int(self.placeCallWithAccountIdCount) + } + + var placeCallAccountId: String? { + return self.placeCallWithAccountIdAccountId + } + + var placeCallParticipantId: String? { + return self.placeCallWithAccountIdToParticipantId + } +} diff --git a/Ring/RingTests/CallProviderDelegateTests.swift b/Ring/RingTests/CallProviderDelegateTests.swift index 58774345ede3c8311412b4b51809d3067d242382..6aceb8bc20ea2e212b938609141dc85d435ef720 100644 --- a/Ring/RingTests/CallProviderDelegateTests.swift +++ b/Ring/RingTests/CallProviderDelegateTests.swift @@ -64,7 +64,7 @@ final class CallProviderDelegateTests: XCTestCase { let expectation = self.expectation(description: "Should have no pending call and no system call") let account = AccountModel() let call = CallModel() - call.participantUri = jamiId1 + call.callUri = jamiId1 // Act callProviderService.handleIncomingCall(account: account, call: call) callProviderService.stopCall(callUUID: call.callUUID, participant: jamiId1) @@ -80,7 +80,7 @@ final class CallProviderDelegateTests: XCTestCase { let expectation = self.expectation(description: "Should have no pending call and one system call") let account = AccountModel() let call = CallModel() - call.participantUri = jamiId1 + call.callUri = jamiId1 // Act callProviderService.previewPendingCall(peerId: jamiId1, withVideo: false, displayName: "", completion: nil) callProviderService.handleIncomingCall(account: account, call: call) @@ -96,7 +96,7 @@ final class CallProviderDelegateTests: XCTestCase { let expectation = self.expectation(description: "Should have no pending call and one system call") let account = AccountModel() let call = CallModel() - call.participantUri = jamiId1 + call.callUri = jamiId1 // Act callProviderService.handleIncomingCall(account: account, call: call) updateCalls(expectation: expectation, jamiId: jamiId1) diff --git a/Ring/RingTests/CallTestUtils.swift b/Ring/RingTests/CallTestUtils.swift new file mode 100644 index 0000000000000000000000000000000000000000..d9968f1f0b11590fb95a278479f6dcf451ba5a66 --- /dev/null +++ b/Ring/RingTests/CallTestUtils.swift @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import XCTest +import RxSwift +import RxRelay +@testable import Ring + +// MARK: - Test Constants + +enum CallTestConstants { + static let accountId = "test-account-id" + static let callId = "test-call-id" + static let invalidCallId = "invalid-call-id" + static let profileUri = "test-uri" + static let participantUri = "test-participant" + static let displayName = "John Doe" + static let registeredName = "john" + static let messageContent = "test message" +} + +// MARK: - MIME Types + +enum TestMIMETypes { + static let textPlain = "text/plain" + static let vCard = "x-ring/ring.profile.vcard;" +} + +// MARK: - Media Types + +enum TestMediaTypes { + static let audio = MediaAttributeValue.audio.rawValue + static let video = MediaAttributeValue.video.rawValue +} + +// MARK: - Call Model Extensions + +extension CallModel { + /// Creates a call model for testing with basic properties + static func createTestCall( + withCallId callId: String = CallTestConstants.callId, + accountId: String = CallTestConstants.accountId, + participantUri: String = CallTestConstants.participantUri, + displayName: String = CallTestConstants.displayName, + registeredName: String = CallTestConstants.registeredName + ) -> CallModel { + let call = CallModel() + call.callId = callId + call.accountId = accountId + call.callUri = participantUri + call.displayName = displayName + call.registeredName = registeredName + return call + } +} + +// MARK: - Account Model Extensions + +extension AccountModel { + /// Creates an account model for testing with basic properties + static func createTestAccount(withId id: String = CallTestConstants.accountId) -> AccountModel { + let accountModel = AccountModel() + accountModel.id = id + + let details: NSDictionary = [ConfigKey.accountUsername.rawValue: id] + let accountDetailsDict = details as NSDictionary? as? [String: String] ?? nil + let accountDetails = AccountConfigModel(withDetails: accountDetailsDict) + + accountModel.details = accountDetails + return accountModel + } +} + +// MARK: - Profile Extensions + +extension Profile { + /// Creates a profile for testing with basic properties + static func createTestProfile(withUri uri: String = CallTestConstants.profileUri) -> Profile { + return Profile(uri: uri, type: "RING") + } +} + +// MARK: - Media Helpers + +struct TestMediaFactory { + /// Creates an audio media entry for testing + static func createAudioMedia( + label: String = "audio_0", + muted: Bool = false, + enabled: Bool = true + ) -> [String: String] { + return [ + MediaAttributeKey.mediaType.rawValue: TestMediaTypes.audio, + MediaAttributeKey.label.rawValue: label, + MediaAttributeKey.muted.rawValue: muted ? "true" : "false", + MediaAttributeKey.enabled.rawValue: enabled ? "true" : "false" + ] + } + + /// Creates a video media entry for testing + static func createVideoMedia( + label: String = "video_0", + muted: Bool = false, + enabled: Bool = true, + source: String = "camera" + ) -> [String: String] { + return [ + MediaAttributeKey.mediaType.rawValue: TestMediaTypes.video, + MediaAttributeKey.label.rawValue: label, + MediaAttributeKey.muted.rawValue: muted ? "true" : "false", + MediaAttributeKey.enabled.rawValue: enabled ? "true" : "false", + MediaAttributeKey.source.rawValue: source + ] + } +} diff --git a/Ring/RingTests/CallsServiceTests.swift b/Ring/RingTests/CallsServiceTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..4b7aba3d6bae9d83fdfd340be3dad7798a9eba7c --- /dev/null +++ b/Ring/RingTests/CallsServiceTests.swift @@ -0,0 +1,294 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import XCTest +import RxSwift +import RxRelay +@testable import Ring + +class CallsServiceTests: XCTestCase { + + private var callsService: CallsService! + private var mockCallsAdapter: ObjCMockCallsAdapter! + private var mockDBManager: DBManager! + private var disposeBag: DisposeBag! + + override func setUp() { + super.setUp() + mockCallsAdapter = ObjCMockCallsAdapter() + mockDBManager = DBManager(profileHepler: ProfileDataHelper(), + conversationHelper: ConversationDataHelper(), + interactionHepler: InteractionDataHelper(), + dbConnections: DBContainer()) + disposeBag = DisposeBag() + + callsService = CallsService(withCallsAdapter: mockCallsAdapter, dbManager: mockDBManager) + } + + override func tearDown() { + disposeBag = nil + callsService = nil + mockCallsAdapter = nil + mockDBManager = nil + super.tearDown() + } + + func testDidChangeCallState_WithNonFinishedState() { + let callId = CallTestConstants.callId + let accountId = CallTestConstants.accountId + let state = CallState.ringing.rawValue + + let callDictionary = [ + CallDetailKey.displayNameKey.rawValue: CallTestConstants.displayName, + CallDetailKey.accountIdKey.rawValue: accountId + ] + + mockCallsAdapter.callDetailsReturnValue = callDictionary + + var capturedCall: CallModel? + callsService.callUpdates + .take(1) + .subscribe(onNext: { call in + capturedCall = call + }) + .disposed(by: disposeBag) + + callsService.didChangeCallState(withCallId: callId, state: state, accountId: accountId, stateCode: 0) + + XCTAssertNotNil(capturedCall, "Call should be captured") + XCTAssertEqual(capturedCall?.callId, callId, "Call ID should match") + XCTAssertEqual(capturedCall?.state, CallState.ringing, "Call state should match") + XCTAssertEqual(capturedCall?.displayName, CallTestConstants.displayName, "Display name should match") + XCTAssertEqual(callsService.calls.get().count, 1, "Calls store should contain one call") + } + + func testDidChangeCallState_WithFinishedState() { + let callId = CallTestConstants.callId + let accountId = CallTestConstants.accountId + + let initialCallDictionary = [ + CallDetailKey.displayNameKey.rawValue: CallTestConstants.displayName, + CallDetailKey.accountIdKey.rawValue: accountId + ] + + mockCallsAdapter.callDetailsReturnValue = initialCallDictionary + + let initialCallExpectation = XCTestExpectation(description: "Initial call added") + + callsService.callUpdates + .take(1) + .subscribe(onNext: { _ in + initialCallExpectation.fulfill() + }) + .disposed(by: disposeBag) + + callsService.didChangeCallState(withCallId: callId, state: CallState.ringing.rawValue, accountId: accountId, stateCode: 0) + + wait(for: [initialCallExpectation], timeout: 1.0) + + XCTAssertEqual(callsService.calls.get().count, 1, "Calls store should contain one call initially") + + let callRemovalExpectation = XCTestExpectation(description: "Call removed") + + DispatchQueue.main.async { + self.callsService.didChangeCallState(withCallId: callId, state: CallState.over.rawValue, accountId: accountId, stateCode: 0) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + XCTAssertEqual(self.callsService.calls.get().count, 0, "Call should be removed from store") + callRemovalExpectation.fulfill() + } + } + + wait(for: [callRemovalExpectation], timeout: 2.0) + } + + func testReceivingCall() { + let callId = CallTestConstants.callId + let accountId = CallTestConstants.accountId + let uri = CallTestConstants.participantUri + + let callDictionary = [ + CallDetailKey.displayNameKey.rawValue: CallTestConstants.displayName, + CallDetailKey.accountIdKey.rawValue: accountId, + CallDetailKey.callTypeKey.rawValue: "0"] + + mockCallsAdapter.callDetailsReturnValue = callDictionary + + let eventExpectation = XCTestExpectation(description: "Event emitted") + eventExpectation.assertForOverFulfill = false + + let callsStoreExpectation = XCTestExpectation(description: "Calls store updated") + + var capturedEvent: ServiceEvent? + + callsService.sharedResponseStream + .subscribe(onNext: { event in + if event.eventType == .incomingCall { + capturedEvent = event + eventExpectation.fulfill() + } + }) + .disposed(by: disposeBag) + + callsService.calls.observable + .skip(1) // Skip the initial empty value + .take(1) + .subscribe(onNext: { calls in + if calls.count == 1 && calls[callId] != nil { + callsStoreExpectation.fulfill() + } + }) + .disposed(by: disposeBag) + + callsService.receivingCall(withAccountId: accountId, callId: callId, fromURI: uri, withMedia: [TestMediaFactory.createAudioMedia()]) + + wait(for: [eventExpectation, callsStoreExpectation], timeout: 3.0) + + XCTAssertEqual(callsService.calls.get().count, 1, "Calls store should contain one call") + XCTAssertNotNil(callsService.calls.get()[callId], "Call should exist in store with correct ID") + + if let call = callsService.calls.get()[callId] { + XCTAssertEqual(call.callType, .incoming, "Call type should be incoming") + } + + XCTAssertNotNil(capturedEvent, "Event should be captured") + XCTAssertEqual(capturedEvent?.eventType, .incomingCall, "Event type should be incomingCall") + } + + func testMediaOperations() async { + let callId = CallTestConstants.callId + let accountId = CallTestConstants.accountId + + let initialCallDictionary = [ + CallDetailKey.displayNameKey.rawValue: CallTestConstants.displayName, + CallDetailKey.accountIdKey.rawValue: accountId + ] + + mockCallsAdapter.callDetailsReturnValue = initialCallDictionary + + let initialCallExpectation = XCTestExpectation(description: "Initial call added") + + callsService.callUpdates + .take(1) + .subscribe(onNext: { _ in + initialCallExpectation.fulfill() + }) + .disposed(by: disposeBag) + + callsService.didChangeCallState(withCallId: callId, state: CallState.current.rawValue, accountId: accountId, stateCode: 0) + + await fulfillment(of: [initialCallExpectation], timeout: 1.0) + + XCTAssertEqual(callsService.calls.get().count, 1, "Calls store should contain one call") + + let muteOperationExpectation = XCTestExpectation(description: "Audio mute operation completed") + + callsService.callUpdates + .skip(1) // Skip the first update which was the initial call + .take(1) + .subscribe(onNext: { call in + if call.callId == callId && call.audioMuted { + muteOperationExpectation.fulfill() + } + }) + .disposed(by: disposeBag) + + callsService.audioMuted(call: callId, mute: true) + + await fulfillment(of: [muteOperationExpectation], timeout: 2.0) + + let call = callsService.call(callID: callId) + XCTAssertNotNil(call, "Call should exist") + XCTAssertTrue(call?.audioMuted ?? false, "Audio should be muted") + } + + func testAcceptCall() { + let callId = CallTestConstants.callId + let accountId = CallTestConstants.accountId + + let initialCallDictionary = [ + CallDetailKey.displayNameKey.rawValue: CallTestConstants.displayName, + CallDetailKey.accountIdKey.rawValue: accountId + ] + + mockCallsAdapter.callDetailsReturnValue = initialCallDictionary + mockCallsAdapter.acceptCallReturnValue = true + + callsService.didChangeCallState(withCallId: callId, state: CallState.incoming.rawValue, accountId: accountId, stateCode: 0) + + let expectation = XCTestExpectation(description: "Accept call completes") + + callsService.accept(callId: callId) + .subscribe( + onCompleted: { + expectation.fulfill() + }, + onError: { error in + XCTFail("Accept call should not fail: \(error)") + } + ) + .disposed(by: disposeBag) + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(mockCallsAdapter.acceptCallIdCount, 1, "Accept call should be called once") + XCTAssertEqual(mockCallsAdapter.acceptCallIdCallId, callId, "Call ID should match") + XCTAssertEqual(mockCallsAdapter.acceptCallIdAccountId, accountId, "Account ID should match") + } + + func testPlaceCall() { + let account = AccountModel.createTestAccount() + let participantId = CallTestConstants.participantUri + let userName = CallTestConstants.displayName + + mockCallsAdapter.placeCallReturnValue = CallTestConstants.callId + mockCallsAdapter.callDetailsReturnValue = [ + CallDetailKey.displayNameKey.rawValue: userName, + CallDetailKey.accountIdKey.rawValue: account.id + ] + + let expectation = XCTestExpectation(description: "Place call completes") + + var resultCall: CallModel? + + callsService.placeCall( + withAccount: account, + toParticipantId: participantId, + userName: userName, + videoSource: "camera", + isAudioOnly: false + ) + .subscribe( + onSuccess: { call in + resultCall = call + expectation.fulfill() + }, + onFailure: { error in + XCTFail("Place call should not fail: \(error)") + } + ) + .disposed(by: disposeBag) + + wait(for: [expectation], timeout: 1.0) + + XCTAssertNotNil(resultCall, "Call model should be returned") + XCTAssertEqual(resultCall?.callId, CallTestConstants.callId, "Call ID should match") + XCTAssertEqual(resultCall?.displayName, userName, "Display name should match") + XCTAssertEqual(resultCall?.callType, .outgoing, "Call type should be outgoing") + } +} diff --git a/Ring/RingTests/ConferenceManagementServiceTests.swift b/Ring/RingTests/ConferenceManagementServiceTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..5385d7cecdaff58cfa89ef7a960841b066039832 --- /dev/null +++ b/Ring/RingTests/ConferenceManagementServiceTests.swift @@ -0,0 +1,979 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import XCTest +import RxSwift +import RxRelay +@testable import Ring + +// swiftlint:disable type_body_length +class ConferenceManagementServiceTests: XCTestCase { + + private enum TestConstants { + static let conferenceId = "test-conference-id" + static let secondCallId = "test-call-id-2" + static let thirdCallId = "test-call-id-3" + static let participantURI = "test-participant-uri" + static let deviceId = "test-device-id" + static let streamId = "test-stream-id" + } + + private var conferenceManagementService: ConferenceManagementService! + private var mockCallsAdapter: ObjCMockCallsAdapter! + private var calls: SynchronizedRelay<CallsDictionary>! + private var callUpdates: ReplaySubject<CallModel>! + private var disposeBag: DisposeBag! + private var testCall: CallModel! + private var testConference: CallModel! + private var queueHelper: ThreadSafeQueueHelper! + + override func setUp() { + super.setUp() + setupMocks() + setupTestCalls() + setupService() + } + + override func tearDown() { + mockCallsAdapter = nil + calls = nil + callUpdates = nil + disposeBag = nil + testCall = nil + testConference = nil + conferenceManagementService = nil + queueHelper = nil + super.tearDown() + } + + private func setupMocks() { + mockCallsAdapter = ObjCMockCallsAdapter() + callUpdates = ReplaySubject<CallModel>.create(bufferSize: 1) + queueHelper = ThreadSafeQueueHelper(label: "com.ring.callsManagementTest", qos: .userInitiated) + calls = SynchronizedRelay<CallsDictionary>(initialValue: [:], queueHelper: queueHelper) + disposeBag = DisposeBag() + } + + private func setupTestCalls() { + testCall = CallModel.createTestCall() + + testConference = CallModel.createTestCall(withCallId: TestConstants.conferenceId) + testConference.participantsCallId = Set([CallTestConstants.callId, TestConstants.secondCallId]) + + var callsDict = [String: CallModel]() + callsDict[CallTestConstants.callId] = testCall + callsDict[TestConstants.conferenceId] = testConference + callsDict[TestConstants.secondCallId] = CallModel.createTestCall(withCallId: TestConstants.secondCallId) + + calls.update { calls in + calls.merge(callsDict, uniquingKeysWith: { $1 }) + } + } + + private func setupService() { + conferenceManagementService = ConferenceManagementService( + callsAdapter: mockCallsAdapter, + calls: calls, + callUpdates: callUpdates + ) + } + + private func setupMockConferenceCalls(_ callIds: [String]) { + mockCallsAdapter.getConferenceCallsReturnValue = callIds + } + + private func setupMockGetConferenceInfo(participants: [[String: String]]) { + mockCallsAdapter.getConferenceInfoReturnValue = participants + } + + private func setupMockGetConferenceDetails(details: [String: String]) { + mockCallsAdapter.getConferenceDetailsReturnValue = details + } + + private func setupCallWithSingleParticipant(_ callId: String) { + var callsDict = calls.get() + let singleCall = CallModel.createTestCall(withCallId: callId) + singleCall.participantsCallId = Set([callId]) // One participant (itself) + callsDict[callId] = singleCall + calls.update { calls in + calls.merge(callsDict, uniquingKeysWith: { $1 }) + } + } + + private func setupCallWithMultipleParticipants(_ callId: String, participants: [String]) { + var callsDict = calls.get() + let conferenceCall = CallModel.createTestCall(withCallId: callId) + conferenceCall.participantsCallId = Set(participants) + callsDict[callId] = conferenceCall + calls.update { calls in + calls.merge(callsDict, uniquingKeysWith: { $1 }) + } + } + + private func setupCallWithLayout(_ callId: String, layout: CallLayout) { + var callsDict = calls.get() + let call = callsDict[callId] ?? CallModel.createTestCall(withCallId: callId) + call.layout = layout + callsDict[callId] = call + calls.update { calls in + calls.merge(callsDict, uniquingKeysWith: { $1 }) + } + } + + private func verifyPendingConference(forCall callId: String, expectedConference: String) { + if let pendingConf = conferenceManagementService.shouldCallBeAddedToConference(callId: callId) { + XCTAssertEqual(pendingConf, expectedConference, "Call should be added to the correct pending conference") + } else { + XCTFail("Call should have been added to a pending conference") + } + } + + private func expectConferenceEvent(conferenceId: String, state: ConferenceState) -> XCTestExpectation { + let expectation = XCTestExpectation(description: "Conference \(state.rawValue) event published") + + conferenceManagementService.currentConferenceEvent + .skip(1) // Skip the initial empty value + .take(1) + .subscribe(onNext: { event in + XCTAssertEqual(event.conferenceID, conferenceId, "Conference ID should match") + XCTAssertEqual(event.state, state.rawValue, "State should be \(state.rawValue)") + expectation.fulfill() + }) + .disposed(by: disposeBag) + + return expectation + } + + private func verifyAdapter<T: Equatable>( + property: T?, + expectedValue: T, + message: String + ) { + XCTAssertEqual(property, expectedValue, message) + } + + private func createParticipantInfo(uri: String, isModerator: Bool, isActive: Bool = true) -> [String: String] { + return [ + "uri": uri, + "isModerator": isModerator ? "true" : "false", + "active": isActive ? "true" : "false" + ] + } + + private func setupConferenceInfoWithParticipants(conferenceId: String, participants: [[String: String]]) async { + await conferenceManagementService.handleConferenceInfoUpdated( + conference: conferenceId, + info: participants + ) + } + + private func verifyModerator(participantId: String, conferenceId: String, expectedResult: Bool, message: String) { + XCTAssertEqual( + conferenceManagementService.isModerator( + participantId: participantId, + inConference: conferenceId + ), + expectedResult, + message + ) + } + + private func verifyStoredParticipants(forConference conferenceId: String, count: Int, moderatorCount: Int, moderatorUris: [String] = []) { + let storedParticipants = conferenceManagementService.getConferenceParticipants(for: conferenceId) + XCTAssertNotNil(storedParticipants, "Participants should be stored") + XCTAssertEqual(storedParticipants?.count, count, "Should have \(count) participants") + + if let participants = storedParticipants { + let moderators = participants.filter { $0.isModerator } + XCTAssertEqual(moderators.count, moderatorCount, "\(moderatorCount) participant(s) should be moderator(s)") + + for moderatorUri in moderatorUris where !moderatorUri.isEmpty { + XCTAssertTrue( + moderators.contains(where: { $0.uri == moderatorUri }), + "Participant with URI '\(moderatorUri)' should be a moderator" + ) + } + } + } + + func testJoinConference_WithSingleCall() { + let expectation = XCTestExpectation(description: "Join conference completed") + setupCallWithSingleParticipant(CallTestConstants.callId) + + Task { + await conferenceManagementService.joinConference(confID: TestConstants.conferenceId, callID: CallTestConstants.callId) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + verifyAdapter( + property: mockCallsAdapter.joinConferenceCallCount, + expectedValue: 1, + message: "joinConference should be called for a single call" + ) + verifyAdapter( + property: mockCallsAdapter.joinConferencesCallCount, + expectedValue: 0, + message: "joinConferences should not be called for a single call" + ) + verifyAdapter( + property: mockCallsAdapter.joinConferenceConferenceId, + expectedValue: TestConstants.conferenceId, + message: "Conference ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.joinConferenceCallId, + expectedValue: CallTestConstants.callId, + message: "Call ID should match" + ) + + let conferenceId = conferenceManagementService.shouldCallBeAddedToConference(callId: CallTestConstants.callId) + XCTAssertEqual(conferenceId, TestConstants.conferenceId, "Call should be added to the expected pending conference") + } + + func testJoinConference_WithConferenceCall() { + let expectation = XCTestExpectation(description: "Join conference completed") + setupCallWithMultipleParticipants( + TestConstants.secondCallId, + participants: [TestConstants.secondCallId, TestConstants.thirdCallId] + ) + + Task { + await conferenceManagementService.joinConference(confID: TestConstants.conferenceId, callID: TestConstants.secondCallId) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + verifyAdapter( + property: mockCallsAdapter.joinConferenceCallCount, + expectedValue: 0, + message: "joinConference should not be called for a conference call" + ) + verifyAdapter( + property: mockCallsAdapter.joinConferencesCallCount, + expectedValue: 1, + message: "joinConferences should be called for a conference call" + ) + verifyAdapter( + property: mockCallsAdapter.joinConferencesConferenceId, + expectedValue: TestConstants.conferenceId, + message: "First conference ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.joinConferencesSecondConferenceId, + expectedValue: TestConstants.secondCallId, + message: "Second conference ID should match" + ) + + let conferenceId = conferenceManagementService.shouldCallBeAddedToConference(callId: TestConstants.secondCallId) + XCTAssertEqual(conferenceId, TestConstants.conferenceId, "Call should be added to the expected pending conference") + } + + func testJoinCall() { + let expectation = XCTestExpectation(description: "Join call completed") + + Task { + await conferenceManagementService.joinCall(firstCallId: CallTestConstants.callId, secondCallId: TestConstants.secondCallId) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Verify the adapter was called with correct parameters + verifyAdapter( + property: mockCallsAdapter.joinCallCallCount, + expectedValue: 1, + message: "Join call should be called" + ) + verifyAdapter( + property: mockCallsAdapter.joinCallFirstCallId, + expectedValue: CallTestConstants.callId, + message: "First call ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.joinCallSecondCallId, + expectedValue: TestConstants.secondCallId, + message: "Second call ID should match" + ) + + let conferenceId = conferenceManagementService.shouldCallBeAddedToConference(callId: TestConstants.secondCallId) + XCTAssertEqual(conferenceId, CallTestConstants.callId, "Call should be added to the expected pending conference") + } + + func testAddCall() { + let newCall = CallModel.createTestCall(withCallId: TestConstants.thirdCallId) + + let inConferenceExpectation = XCTestExpectation(description: "Call added to conference") + let addCallExpectation = XCTestExpectation(description: "Add call completed") + + conferenceManagementService.inConferenceCalls + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, TestConstants.thirdCallId, "Added call should match the expected call") + inConferenceExpectation.fulfill() + }) + .disposed(by: disposeBag) + + Task { + await conferenceManagementService.addCall(call: newCall, to: TestConstants.conferenceId) + addCallExpectation.fulfill() + } + + wait(for: [inConferenceExpectation, addCallExpectation], timeout: 1.0) + + let conferenceId = conferenceManagementService.shouldCallBeAddedToConference(callId: TestConstants.thirdCallId) + XCTAssertEqual(conferenceId, TestConstants.conferenceId, "Call should be added to the expected pending conference") + } + + // MARK: - Hang Up Tests + func testHangUpCall() { + let expectation = XCTestExpectation(description: "Hang up call completed") + mockCallsAdapter.hangUpCallReturnValue = true + setupCallWithSingleParticipant(CallTestConstants.callId) + + let hangupCompletable = conferenceManagementService.hangUpCallOrConference(callId: CallTestConstants.callId, isSwarm: false) + + hangupCompletable + .subscribe(onCompleted: { + expectation.fulfill() + }, onError: { error in + XCTFail("Hang up call should complete without error: \(error)") + }) + .disposed(by: disposeBag) + + wait(for: [expectation], timeout: 1.0) + verifyAdapter( + property: mockCallsAdapter.hangUpCallCallCount, + expectedValue: 1, + message: "Hang up call should be called once" + ) + verifyAdapter( + property: mockCallsAdapter.hangUpConferenceCallCount, + expectedValue: 0, + message: "Hang up conference should not be called" + ) + verifyAdapter( + property: mockCallsAdapter.hangUpCallCallId, + expectedValue: CallTestConstants.callId, + message: "Call ID should match" + ) + } + + func testHangUpConference() { + let expectation = XCTestExpectation(description: "Hang up conference completed") + mockCallsAdapter.hangUpConferenceReturnValue = true + setupCallWithMultipleParticipants( + TestConstants.conferenceId, + participants: [CallTestConstants.callId, TestConstants.secondCallId] + ) + + let hangupCompletable = conferenceManagementService.hangUpCallOrConference(callId: TestConstants.conferenceId, isSwarm: false) + + hangupCompletable + .subscribe(onCompleted: { + expectation.fulfill() + }, onError: { error in + XCTFail("Hang up conference should complete without error: \(error)") + }) + .disposed(by: disposeBag) + + wait(for: [expectation], timeout: 1.0) + verifyAdapter( + property: mockCallsAdapter.hangUpCallCallCount, + expectedValue: 0, + message: "Hang up call should not be called" + ) + verifyAdapter( + property: mockCallsAdapter.hangUpConferenceCallCount, + expectedValue: 1, + message: "Hang up conference should be called once" + ) + verifyAdapter( + property: mockCallsAdapter.hangUpConferenceCallId, + expectedValue: TestConstants.conferenceId, + message: "Conference ID should match" + ) + } + + func testHangUpCallFailure() { + let expectation = XCTestExpectation(description: "Hang up call failed") + mockCallsAdapter.hangUpCallReturnValue = false + + let hangupCompletable = conferenceManagementService.hangUpCallOrConference(callId: CallTestConstants.callId, isSwarm: false) + + hangupCompletable + .subscribe(onCompleted: { + XCTFail("Hang up call should not complete") + }, onError: { error in + if let callError = error as? CallServiceError, callError == CallServiceError.hangUpCallFailed { + expectation.fulfill() + } else { + XCTFail("Unexpected error: \(error)") + } + }) + .disposed(by: disposeBag) + + wait(for: [expectation], timeout: 1.0) + verifyAdapter( + property: mockCallsAdapter.hangUpCallCallCount, + expectedValue: 1, + message: "Hang up call should be called once" + ) + } + + // MARK: - Participant Tests + func testIsParticipant() { + let activeParticipantId = "active-participant-id" + let inactiveParticipantId = "inactive-participant-id" + let nonExistentId = "non-existent-id" + + let participants = [ + createParticipantInfo(uri: activeParticipantId, isModerator: false, isActive: true), + createParticipantInfo(uri: inactiveParticipantId, isModerator: false, isActive: false) + ] + + mockCallsAdapter.getConferenceInfoReturnValue = participants + + let infoUpdateExpectation = XCTestExpectation(description: "Conference info updated") + + Task { + await conferenceManagementService.handleConferenceInfoUpdated( + conference: TestConstants.conferenceId, + info: participants + ) + infoUpdateExpectation.fulfill() + } + + wait(for: [infoUpdateExpectation], timeout: 1.0) + + // Test active participant + let activeResult = conferenceManagementService.isParticipant( + participantURI: activeParticipantId, + activeIn: TestConstants.conferenceId, + accountId: CallTestConstants.accountId + ) + XCTAssertEqual(activeResult, true, "User with active=true should be identified as an active participant") + + // Test inactive participant + let inactiveResult = conferenceManagementService.isParticipant( + participantURI: inactiveParticipantId, + activeIn: TestConstants.conferenceId, + accountId: CallTestConstants.accountId + ) + XCTAssertEqual(inactiveResult, false, "User with active=false should not be identified as an active participant") + + // Test non-existent participant + let nonExistentResult = conferenceManagementService.isParticipant( + participantURI: nonExistentId, + activeIn: TestConstants.conferenceId, + accountId: CallTestConstants.accountId + ) + XCTAssertTrue(nonExistentResult == nil || nonExistentResult == false, "Non-existent user should return nil or false") + } + + func testIsModerator() { + let moderatorId = "moderator-id" + let participantId = "participant-id" + let nonExistentId = "non-existent-id" + + let participants = [ + createParticipantInfo(uri: moderatorId, isModerator: true), + createParticipantInfo(uri: participantId, isModerator: false) + ] + + let infoUpdateExpectation = XCTestExpectation(description: "Conference info updated") + + Task { + await conferenceManagementService.handleConferenceInfoUpdated( + conference: TestConstants.conferenceId, + info: participants + ) + infoUpdateExpectation.fulfill() + } + + wait(for: [infoUpdateExpectation], timeout: 1.0) + + verifyModerator( + participantId: moderatorId, + conferenceId: TestConstants.conferenceId, + expectedResult: true, + message: "User with isModerator=true should be identified as a moderator" + ) + + verifyModerator( + participantId: participantId, + conferenceId: TestConstants.conferenceId, + expectedResult: false, + message: "User with isModerator=false should not be identified as a moderator" + ) + + verifyModerator( + participantId: nonExistentId, + conferenceId: TestConstants.conferenceId, + expectedResult: false, + message: "Non-existent user should not be identified as a moderator" + ) + } + + // MARK: - Layout Tests + func testSetActiveParticipant_Maximize() { + setupMockGetConferenceInfo(participants: [ + ["uri": TestConstants.participantURI, "active": "true"] + ]) + setupCallWithLayout(TestConstants.conferenceId, layout: .grid) + + let expectation = XCTestExpectation(description: "Set active participant completed") + + conferenceManagementService.setActiveParticipant( + conferenceId: TestConstants.conferenceId, + maximixe: true, + jamiId: TestConstants.participantURI + ) + expectation.fulfill() + + wait(for: [expectation], timeout: 1.0) + + verifyAdapter( + property: mockCallsAdapter.setActiveParticipantCallCount, + expectedValue: 1, + message: "Set active participant should be called" + ) + verifyAdapter( + property: mockCallsAdapter.setActiveParticipantJamiId, + expectedValue: TestConstants.participantURI, + message: "Jami ID should match" + ) + + verifyAdapter( + property: mockCallsAdapter.setConferenceLayoutCallCount, + expectedValue: 1, + message: "Set conference layout should be called" + ) + verifyAdapter( + property: mockCallsAdapter.setConferenceLayoutLayout, + expectedValue: CallLayout.oneWithSmal.rawValue, + message: "Layout should be 'oneWithSmall' when maximizing from grid" + ) + } + + func testSetActiveParticipant_OneWithSmall_ToGrid() { + setupMockGetConferenceInfo(participants: [ + ["uri": TestConstants.participantURI, "active": "true"] + ]) + setupCallWithLayout(TestConstants.conferenceId, layout: .oneWithSmal) + + let expectation = XCTestExpectation(description: "Set active participant completed") + conferenceManagementService.setActiveParticipant( + conferenceId: TestConstants.conferenceId, + maximixe: false, + jamiId: TestConstants.participantURI + ) + expectation.fulfill() + + wait(for: [expectation], timeout: 1.0) + + verifyAdapter( + property: mockCallsAdapter.setActiveParticipantCallCount, + expectedValue: 1, + message: "Set active participant should be called" + ) + + verifyAdapter( + property: mockCallsAdapter.setConferenceLayoutCallCount, + expectedValue: 1, + message: "Set conference layout should be called" + ) + verifyAdapter( + property: mockCallsAdapter.setConferenceLayoutLayout, + expectedValue: CallLayout.grid.rawValue, + message: "Layout should be 'grid' when not maximizing from oneWithSmal" + ) + } + + func testSetActiveParticipant_OneWithSmall_ToOne() { + setupMockGetConferenceInfo(participants: [ + ["uri": TestConstants.participantURI, "active": "true"] + ]) + setupCallWithLayout(TestConstants.conferenceId, layout: .oneWithSmal) + + let expectation = XCTestExpectation(description: "Set active participant completed") + + conferenceManagementService.setActiveParticipant( + conferenceId: TestConstants.conferenceId, + maximixe: true, + jamiId: TestConstants.participantURI + ) + expectation.fulfill() + + wait(for: [expectation], timeout: 1.0) + + verifyAdapter( + property: mockCallsAdapter.setActiveParticipantCallCount, + expectedValue: 1, + message: "Set active participant should be called" + ) + + verifyAdapter( + property: mockCallsAdapter.setConferenceLayoutCallCount, + expectedValue: 1, + message: "Set conference layout should be called" + ) + verifyAdapter( + property: mockCallsAdapter.setConferenceLayoutLayout, + expectedValue: CallLayout.one.rawValue, + message: "Layout should be 'one' when maximizing from oneWithSmal" + ) + } + + // MARK: - Conference Event Tests + func testHandleConferenceCreated() { + setupMockConferenceCalls([CallTestConstants.callId, TestConstants.secondCallId]) + let joinCallExpectation = XCTestExpectation(description: "Join call completed") + + conferenceManagementService.joinCall(firstCallId: CallTestConstants.callId, secondCallId: TestConstants.secondCallId) + joinCallExpectation.fulfill() + + wait(for: [joinCallExpectation], timeout: 1.0) + + setupMockGetConferenceDetails(details: ["accountId": CallTestConstants.accountId, "audioOnly": "false"]) + + let eventExpectation = expectConferenceEvent( + conferenceId: TestConstants.conferenceId, + state: .conferenceCreated + ) + + let createExpectation = XCTestExpectation(description: "Conference created completed") + Task { + await conferenceManagementService.handleConferenceCreated(conferenceId: TestConstants.conferenceId, conversationId: "conversationId", accountId: CallTestConstants.accountId) + createExpectation.fulfill() + } + + wait(for: [createExpectation, eventExpectation], timeout: 1.0) + XCTAssertNotNil(calls.get()[TestConstants.conferenceId], "Conference should be added to calls") + verifyAdapter( + property: mockCallsAdapter.getConferenceCallsCallCount, + expectedValue: 1, + message: "Get conference calls should be called" + ) + verifyAdapter( + property: mockCallsAdapter.getConferenceDetailsCallCount, + expectedValue: 1, + message: "Get conference details should be called" + ) + } + + func testHandleConferenceRemoved() { + let eventExpectation = XCTestExpectation(description: "Conference removed event published") + let thirdCall = CallModel.createTestCall(withCallId: TestConstants.thirdCallId) + let addCallExpectation = XCTestExpectation(description: "Add call completed") + + conferenceManagementService.addCall(call: thirdCall, to: TestConstants.conferenceId) + addCallExpectation.fulfill() + + wait(for: [addCallExpectation], timeout: 1.0) + + var eventCount = 0 + conferenceManagementService.currentConferenceEvent + .skip(1) // Skip the initial empty value + .take(2) // We expect two events (infoUpdated and conferenceDestroyed) + .subscribe(onNext: { event in + eventCount += 1 + if eventCount == 2 { + XCTAssertEqual(event.state, ConferenceState.conferenceDestroyed.rawValue, "Second event should be conferenceDestroyed") + eventExpectation.fulfill() + } + }) + .disposed(by: disposeBag) + + Task { + await conferenceManagementService.handleConferenceRemoved(conference: TestConstants.conferenceId) + } + + wait(for: [eventExpectation], timeout: 1.0) + XCTAssertNil(calls.get()[TestConstants.conferenceId], "Conference should be removed from calls") + XCTAssertNil( + conferenceManagementService.shouldCallBeAddedToConference(callId: TestConstants.thirdCallId), + "Conference should be removed from pending conferences" + ) + } + + func testHandleConferenceInfoUpdated() { + let moderatorUri = "participant1" + let regularUri = "participant2" + + let participants = [ + createParticipantInfo(uri: moderatorUri, isModerator: true), + createParticipantInfo(uri: regularUri, isModerator: false) + ] + + let eventExpectation = expectConferenceEvent( + conferenceId: TestConstants.conferenceId, + state: .infoUpdated + ) + + let setupExpectation = XCTestExpectation(description: "Setup completed") + Task { + await conferenceManagementService.handleConferenceInfoUpdated( + conference: TestConstants.conferenceId, + info: participants + ) + setupExpectation.fulfill() + } + + wait(for: [setupExpectation], timeout: 1.0) + + wait(for: [eventExpectation], timeout: 1.0) + + verifyStoredParticipants( + forConference: TestConstants.conferenceId, + count: 2, + moderatorCount: 1, + moderatorUris: [moderatorUri] + ) + } + + // MARK: - Pending Conferences Tests + func testClearPendingConferences() { + let call1 = CallModel.createTestCall(withCallId: CallTestConstants.callId) + let addCallExpectation1 = XCTestExpectation(description: "First add call completed") + let addCallExpectation2 = XCTestExpectation(description: "Second add call completed") + let clearExpectation = XCTestExpectation(description: "Clear pending conferences completed") + + conferenceManagementService.addCall(call: call1, to: TestConstants.conferenceId) + addCallExpectation1.fulfill() + + wait(for: [addCallExpectation1], timeout: 1.0) + + conferenceManagementService.addCall(call: call1, to: TestConstants.secondCallId) + addCallExpectation2.fulfill() + + wait(for: [addCallExpectation2], timeout: 1.0) + + conferenceManagementService.clearPendingConferences(callId: CallTestConstants.callId) + clearExpectation.fulfill() + + wait(for: [clearExpectation], timeout: 1.0) + + XCTAssertNil( + conferenceManagementService.shouldCallBeAddedToConference(callId: CallTestConstants.callId), + "Call should be removed from pending conferences" + ) + } + + func testUpdateConferences() { + setupMockConferenceCalls([CallTestConstants.callId, TestConstants.secondCallId, TestConstants.thirdCallId]) + + var updatedCalls = calls.get() + updatedCalls[TestConstants.thirdCallId] = CallModel.createTestCall(withCallId: TestConstants.thirdCallId) + calls.update { calls in + calls.merge(updatedCalls, uniquingKeysWith: { $1 }) + } + + let callsUpdatedExpectation = XCTestExpectation(description: "Calls updated") + let updateExpectation = XCTestExpectation(description: "Update conferences completed") + + calls.observable + .skip(1) // Skip initial value + .take(1) + .subscribe(onNext: { _ in + callsUpdatedExpectation.fulfill() + }) + .disposed(by: disposeBag) + + Task { + await conferenceManagementService.updateConferences(callId: CallTestConstants.callId) + updateExpectation.fulfill() + } + + wait(for: [updateExpectation, callsUpdatedExpectation], timeout: 1.0) + verifyAdapter( + property: mockCallsAdapter.getConferenceCallsCallCount, + expectedValue: 1, + message: "Get conference calls should be called" + ) + + for callId in [CallTestConstants.callId, TestConstants.secondCallId, TestConstants.thirdCallId] { + XCTAssertEqual( + calls.get()[callId]?.participantsCallId.count, + 3, + "Call \(callId) should have 3 participants" + ) + } + } + + // MARK: - Participant Management Tests + + func testSetModeratorParticipant() { + let expectation = XCTestExpectation(description: "Set moderator participant completed") + setupCallWithSingleParticipant(TestConstants.conferenceId) + + conferenceManagementService.setModeratorParticipant( + confId: TestConstants.conferenceId, + participantId: TestConstants.participantURI, + active: true + ) + expectation.fulfill() + wait(for: [expectation], timeout: 1.0) + + verifyAdapter( + property: mockCallsAdapter.setConferenceModeratorCallCount, + expectedValue: 1, + message: "Set conference moderator should be called" + ) + verifyAdapter( + property: mockCallsAdapter.setConferenceModeratorParticipantId, + expectedValue: TestConstants.participantURI, + message: "Participant ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.setConferenceModeratorConferenceId, + expectedValue: TestConstants.conferenceId, + message: "Conference ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.setConferenceModeratorActive, + expectedValue: true, + message: "Active state should match" + ) + } + + func testHangupParticipant() { + let expectation = XCTestExpectation(description: "Hangup participant completed") + setupCallWithSingleParticipant(TestConstants.conferenceId) + + conferenceManagementService.hangupParticipant( + confId: TestConstants.conferenceId, + participantId: TestConstants.participantURI, + device: TestConstants.deviceId + ) + expectation.fulfill() + + wait(for: [expectation], timeout: 1.0) + + verifyAdapter( + property: mockCallsAdapter.hangupConferenceParticipantCallCount, + expectedValue: 1, + message: "Hangup conference participant should be called" + ) + verifyAdapter( + property: mockCallsAdapter.hangupConferenceParticipantParticipantId, + expectedValue: TestConstants.participantURI, + message: "Participant ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.hangupConferenceParticipantConferenceId, + expectedValue: TestConstants.conferenceId, + message: "Conference ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.hangupConferenceParticipantDeviceId, + expectedValue: TestConstants.deviceId, + message: "Device ID should match" + ) + } + + func testMuteStream() { + let expectation = XCTestExpectation(description: "Mute stream completed") + + conferenceManagementService.muteStream( + confId: TestConstants.conferenceId, + participantId: TestConstants.participantURI, + device: TestConstants.deviceId, + accountId: CallTestConstants.accountId, + streamId: TestConstants.streamId, + state: true + ) + expectation.fulfill() + + wait(for: [expectation], timeout: 1.0) + + verifyAdapter( + property: mockCallsAdapter.muteStreamCallCount, + expectedValue: 1, + message: "Mute stream should be called" + ) + verifyAdapter( + property: mockCallsAdapter.muteStreamParticipantId, + expectedValue: TestConstants.participantURI, + message: "Participant ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.muteStreamConferenceId, + expectedValue: TestConstants.conferenceId, + message: "Conference ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.muteStreamDeviceId, + expectedValue: TestConstants.deviceId, + message: "Device ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.muteStreamStreamId, + expectedValue: TestConstants.streamId, + message: "Stream ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.muteStreamState, + expectedValue: true, + message: "State should match" + ) + } + + func testSetRaiseHand() { + let expectation = XCTestExpectation(description: "Set raise hand completed") + + conferenceManagementService.setRaiseHand( + confId: TestConstants.conferenceId, + participantId: TestConstants.participantURI, + state: true, + accountId: CallTestConstants.accountId, + deviceId: TestConstants.deviceId + ) + expectation.fulfill() + + wait(for: [expectation], timeout: 1.0) + + verifyAdapter( + property: mockCallsAdapter.raiseHandCallCount, + expectedValue: 1, + message: "Raise hand should be called" + ) + verifyAdapter( + property: mockCallsAdapter.raiseHandParticipantId, + expectedValue: TestConstants.participantURI, + message: "Participant ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.raiseHandConferenceId, + expectedValue: TestConstants.conferenceId, + message: "Conference ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.raiseHandDeviceId, + expectedValue: TestConstants.deviceId, + message: "Device ID should match" + ) + verifyAdapter( + property: mockCallsAdapter.raiseHandState, + expectedValue: true, + message: "State should match" + ) + } +} diff --git a/Ring/RingTests/MediaManagementServiceTest.swift b/Ring/RingTests/MediaManagementServiceTest.swift new file mode 100644 index 0000000000000000000000000000000000000000..f0e45e7b7d059c25375c414daa77ece6dd2c87bb --- /dev/null +++ b/Ring/RingTests/MediaManagementServiceTest.swift @@ -0,0 +1,491 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import XCTest +import RxSwift +import RxRelay +@testable import Ring + +class MediaManagementServiceTests: XCTestCase { + + private enum TestConstants { + static let videoCodec = "H264" + static let audioLabel = "audio_0" + static let videoLabel = "video_0" + static let newVideoLabel = "video_new" + } + + private var mediaManagementService: MediaManagementService! + private var mockCallsAdapter: ObjCMockCallsAdapter! + private var calls: SynchronizedRelay<CallsDictionary>! + private var callUpdates: ReplaySubject<CallModel>! + private var disposeBag: DisposeBag! + private var testCall: CallModel! + private var queueHelper: ThreadSafeQueueHelper! + private var responseStream: PublishSubject<ServiceEvent>! + + override func setUp() { + super.setUp() + setupMocks() + setupTestCall() + setupService() + } + + override func tearDown() { + mockCallsAdapter = nil + calls = nil + callUpdates = nil + disposeBag = nil + testCall = nil + mediaManagementService = nil + queueHelper = nil + responseStream = nil + super.tearDown() + } + + private func setupMocks() { + mockCallsAdapter = ObjCMockCallsAdapter() + callUpdates = ReplaySubject<CallModel>.create(bufferSize: 1) + queueHelper = ThreadSafeQueueHelper(label: "com.ring.callsManagementTest", qos: .userInitiated) + calls = SynchronizedRelay<CallsDictionary>(initialValue: [:], queueHelper: queueHelper) + responseStream = PublishSubject<ServiceEvent>() + disposeBag = DisposeBag() + } + + private func setupTestCall() { + testCall = CallModel.createTestCall() + calls.update { calls in + calls[CallTestConstants.callId] = self.testCall + } + } + + private func setupService() { + mediaManagementService = MediaManagementService( + callsAdapter: mockCallsAdapter, + calls: calls, + callUpdates: callUpdates, + responseStream: responseStream + ) + } + + // MARK: - Codec Tests + + func testGetVideoCodec() { + mockCallsAdapter.callDetailsReturnValue = [CallDetailKey.videoCodec.rawValue: TestConstants.videoCodec] + + let result = mediaManagementService.getVideoCodec(call: testCall) + + XCTAssertEqual(result, TestConstants.videoCodec) + XCTAssertEqual(mockCallsAdapter.callDetailsCallCount, 1) + } + + func testGetVideoCodecWithNilResponse() { + mockCallsAdapter.callDetailsReturnValue = nil + + let result = mediaManagementService.getVideoCodec(call: testCall) + + XCTAssertNil(result) + XCTAssertEqual(mockCallsAdapter.callDetailsCallCount, 1) + } + + func testGetVideoCodecWithEmptyDictionary() { + mockCallsAdapter.callDetailsReturnValue = [:] + + let result = mediaManagementService.getVideoCodec(call: testCall) + + XCTAssertNil(result) + XCTAssertEqual(mockCallsAdapter.callDetailsCallCount, 1) + } + + func testGetVideoCodecWithMissingCodecKey() { + mockCallsAdapter.callDetailsReturnValue = ["some_other_key": "some_value"] + + let result = mediaManagementService.getVideoCodec(call: testCall) + + XCTAssertNil(result) + XCTAssertEqual(mockCallsAdapter.callDetailsCallCount, 1) + } + + func testGetVideoCodecWithEmptyCodecValue() { + mockCallsAdapter.callDetailsReturnValue = [CallDetailKey.videoCodec.rawValue: ""] + + let result = mediaManagementService.getVideoCodec(call: testCall) + + XCTAssertEqual(result, "") + XCTAssertEqual(mockCallsAdapter.callDetailsCallCount, 1) + } + + // MARK: - Audio Tests + + func testAudioMuted() async { + let expectation = XCTestExpectation(description: "Audio mute event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertTrue(call.audioMuted) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.audioMuted(call: CallTestConstants.callId, mute: true) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testAudioUnmuted() async { + testCall.audioMuted = true + calls.update { calls in + calls[CallTestConstants.callId] = self.testCall + } + + let expectation = XCTestExpectation(description: "Audio unmute event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertFalse(call.audioMuted) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.audioMuted(call: CallTestConstants.callId, mute: false) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testAudioMutedWithInvalidCallId() async { + await mediaManagementService.audioMuted(call: CallTestConstants.invalidCallId, mute: true) + + XCTAssertEqual(testCall.audioMuted, false, "Call audio mute state should not change") + } + + // MARK: - Video Tests + + func testVideoMuted() async { + let expectation = XCTestExpectation(description: "Video mute event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertTrue(call.videoMuted) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.videoMuted(call: CallTestConstants.callId, mute: true) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testVideoUnmuted() async { + testCall.videoMuted = true + calls.update { calls in + calls[CallTestConstants.callId] = self.testCall + } + + let expectation = XCTestExpectation(description: "Video unmute event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertFalse(call.videoMuted) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.videoMuted(call: CallTestConstants.callId, mute: false) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testVideoMutedWithInvalidCallId() async { + await mediaManagementService.videoMuted(call: CallTestConstants.invalidCallId, mute: true) + + XCTAssertEqual(testCall.videoMuted, false, "Call video mute state should not change") + } + + // MARK: - Remote Recording Tests + + func testHandleRemoteRecordingChanged() async { + let expectation = XCTestExpectation(description: "Remote recording event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertTrue(call.callRecorded) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.handleRemoteRecordingChanged(callId: CallTestConstants.callId, record: true) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testHandleRemoteRecordingDisabled() async { + testCall.callRecorded = true + calls.update { calls in + calls[CallTestConstants.callId] = self.testCall + } + + let expectation = XCTestExpectation(description: "Remote recording disabled event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertFalse(call.callRecorded) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.handleRemoteRecordingChanged(callId: CallTestConstants.callId, record: false) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testHandleRemoteRecordingWithInvalidCallId() async { + await mediaManagementService.handleRemoteRecordingChanged(callId: CallTestConstants.invalidCallId, record: true) + + XCTAssertEqual(testCall.callRecorded, false, "Call recording state should not change") + } + + // MARK: - Call Hold Tests + + func testHandleCallPlacedOnHold() async { + let expectation = XCTestExpectation(description: "Call hold event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertTrue(call.peerHolding) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.handleCallPlacedOnHold(callId: CallTestConstants.callId, holding: true) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + // MARK: - Media Update Tests + + func testCallMediaUpdatedWithExistingMedia() async { + let mediaList: [[String: String]] = [TestMediaFactory.createAudioMedia(label: TestConstants.audioLabel)] + + mockCallsAdapter.callDetailsReturnValue = [ + CallDetailKey.videoCodec.rawValue: TestConstants.videoCodec + ] + + testCall.mediaList = mediaList + calls.update { calls in + calls[CallTestConstants.callId] = self.testCall + } + + let expectation = XCTestExpectation(description: "Call media updated event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertEqual(call.mediaList.count, mediaList.count) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.callMediaUpdated(call: testCall) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testCallMediaUpdatedWithNoExistingMedia() async { + let mediaList: [[String: String]] = [TestMediaFactory.createAudioMedia(label: TestConstants.audioLabel)] + + mockCallsAdapter.currentMediaListReturnValue = mediaList + mockCallsAdapter.callDetailsReturnValue = [ + CallDetailKey.videoCodec.rawValue: TestConstants.videoCodec + ] + + testCall.mediaList = [] + calls.update { calls in + calls[CallTestConstants.callId] = self.testCall + } + + let expectation = XCTestExpectation(description: "Call media updated event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertEqual(call.mediaList.count, mediaList.count) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.callMediaUpdated(call: testCall) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testUpdateCallMediaIfNeeded() async { + let currentMediaList: [[String: String]] = [] + let newMediaList: [[String: String]] = [TestMediaFactory.createAudioMedia(label: TestConstants.audioLabel)] + + mockCallsAdapter.currentMediaListReturnValue = newMediaList + + testCall.mediaList = currentMediaList + calls.update { calls in + calls[CallTestConstants.callId] = self.testCall + } + + let expectation = XCTestExpectation(description: "Call media updated event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertEqual(call.mediaList.count, newMediaList.count) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.updateCallMediaIfNeeded(call: testCall) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testHandleMediaNegotiationStatus() async { + let mediaList: [[String: String]] = [TestMediaFactory.createAudioMedia(label: TestConstants.audioLabel)] + + mockCallsAdapter.callDetailsReturnValue = [CallDetailKey.videoCodec.rawValue: TestConstants.videoCodec] + + let expectation = XCTestExpectation(description: "Media negotiation event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertEqual(call.mediaList.count, mediaList.count) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.handleMediaNegotiationStatus(callId: CallTestConstants.callId, event: "negotiated", media: mediaList) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testHandleMediaChangeRequest() async { + let originalMedia: [[String: String]] = [ + TestMediaFactory.createAudioMedia(label: TestConstants.audioLabel, muted: false, enabled: true) + ] + + let requestedMedia: [[String: String]] = [ + [MediaAttributeKey.mediaType.rawValue: TestMediaTypes.audio, + MediaAttributeKey.label.rawValue: TestConstants.audioLabel], + [MediaAttributeKey.mediaType.rawValue: TestMediaTypes.video, + MediaAttributeKey.label.rawValue: TestConstants.newVideoLabel] + ] + + testCall.mediaList = originalMedia + calls.update { calls in + calls[CallTestConstants.callId] = self.testCall + } + + let expectation = XCTestExpectation(description: "Media change request event published") + + callUpdates + .take(1) + .subscribe(onNext: { call in + XCTAssertEqual(call.callId, self.testCall.callId) + XCTAssertEqual(call.mediaList.count, 2) + + // Check that the original audio media settings are preserved + let audioMedia = call.mediaList.first { $0[MediaAttributeKey.label.rawValue] == TestConstants.audioLabel } + XCTAssertNotNil(audioMedia) + XCTAssertEqual(audioMedia?[MediaAttributeKey.muted.rawValue], "false") + XCTAssertEqual(audioMedia?[MediaAttributeKey.enabled.rawValue], "true") + + // Check that the new video media has default values + let videoMedia = call.mediaList.first { $0[MediaAttributeKey.label.rawValue] == TestConstants.newVideoLabel } + XCTAssertNotNil(videoMedia) + XCTAssertEqual(videoMedia?[MediaAttributeKey.muted.rawValue], "true") + XCTAssertEqual(videoMedia?[MediaAttributeKey.enabled.rawValue], "true") + + expectation.fulfill() + }) + .disposed(by: disposeBag) + + await mediaManagementService.handleMediaChangeRequest(accountId: CallTestConstants.accountId, callId: CallTestConstants.callId, media: requestedMedia) + + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertEqual(mockCallsAdapter.answerMediaChangeResquestCallCount, 1) + XCTAssertEqual(mockCallsAdapter.answerMediaChangeResquestCallId, CallTestConstants.callId) + XCTAssertEqual(mockCallsAdapter.answerMediaChangeResquestAccountId, CallTestConstants.accountId) + } + + func testHandleMediaChangeRequestWithInvalidCallId() async { + await mediaManagementService.handleMediaChangeRequest(accountId: CallTestConstants.accountId, callId: CallTestConstants.invalidCallId, media: []) + + XCTAssertEqual(mockCallsAdapter.answerMediaChangeResquestCallCount, 0, "Should not process invalid call ID") + } + + func testProcessMediaChangeRequest() { + let originalMedia: [[String: String]] = [ + TestMediaFactory.createAudioMedia(label: TestConstants.audioLabel, muted: false, enabled: true) + ] + + let requestedMedia: [[String: String]] = [ + [MediaAttributeKey.mediaType.rawValue: TestMediaTypes.audio, + MediaAttributeKey.label.rawValue: TestConstants.audioLabel], + [MediaAttributeKey.mediaType.rawValue: TestMediaTypes.video, + MediaAttributeKey.label.rawValue: TestConstants.newVideoLabel] + ] + + testCall.mediaList = originalMedia + + let result = mediaManagementService.processMediaChangeRequest(call: testCall, requestedMedia: requestedMedia) + + XCTAssertEqual(result.count, 2, "Should return two media entries") + + let resultAudio = result.first { $0[MediaAttributeKey.label.rawValue] == TestConstants.audioLabel } + XCTAssertNotNil(resultAudio, "Audio media should be present") + XCTAssertEqual(resultAudio?[MediaAttributeKey.muted.rawValue], "false", "Audio mute state should be preserved") + XCTAssertEqual(resultAudio?[MediaAttributeKey.enabled.rawValue], "true", "Audio enabled state should be preserved") + + let resultVideo = result.first { $0[MediaAttributeKey.label.rawValue] == TestConstants.newVideoLabel } + XCTAssertNotNil(resultVideo, "Video media should be present") + XCTAssertEqual(resultVideo?[MediaAttributeKey.muted.rawValue], "true", "New video should be muted by default") + XCTAssertEqual(resultVideo?[MediaAttributeKey.enabled.rawValue], "true", "New video should be enabled by default") + } +} + +extension CallModel { + convenience init(id: String, accountId: String) { + self.init() + self.callId = id + self.accountId = accountId + } +} diff --git a/Ring/RingTests/MessageHandlingServiceTests.swift b/Ring/RingTests/MessageHandlingServiceTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..9c28e6182ae932ded030c023e9d221c909f75ea4 --- /dev/null +++ b/Ring/RingTests/MessageHandlingServiceTests.swift @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import XCTest +import RxSwift +import RxRelay +import Contacts +@testable import Ring + +class MessageHandlingServiceTests: XCTestCase { + + private enum TestConstants { + static let vCardData = "vcard-data" + } + + private var service: MessageHandlingService! + private var mockCallsAdapter: ObjCMockCallsAdapter! + private var mockDBManager: MockDBManager! + private var calls: SynchronizedRelay<CallsDictionary>! + private var messagesStream: PublishSubject<ServiceEvent>! + private var disposeBag: DisposeBag! + private var messageEvents: [ServiceEvent] = [] + private var queueHelper: ThreadSafeQueueHelper! + + override func setUp() { + super.setUp() + setupMocks() + setupService() + setupEventListeners() + } + + override func tearDown() { + service = nil + mockCallsAdapter = nil + mockDBManager = nil + calls = nil + messagesStream = nil + disposeBag = nil + queueHelper = nil + messageEvents = [] + super.tearDown() + } + + private func setupMocks() { + mockCallsAdapter = ObjCMockCallsAdapter() + mockDBManager = MockDBManager(profileHepler: ProfileDataHelper(), + conversationHelper: ConversationDataHelper(), + interactionHepler: InteractionDataHelper(), + dbConnections: DBContainer()) + queueHelper = ThreadSafeQueueHelper(label: "com.ring.callsManagementTest", qos: .userInitiated) + calls = SynchronizedRelay<CallsDictionary>(initialValue: [:], queueHelper: queueHelper) + messagesStream = PublishSubject<ServiceEvent>() + disposeBag = DisposeBag() + messageEvents = [] + } + + private func setupService() { + service = MessageHandlingService( + callsAdapter: mockCallsAdapter, + dbManager: mockDBManager, + calls: calls, + newMessagesStream: messagesStream + ) + } + + private func setupEventListeners() { + messagesStream + .subscribe(onNext: { [weak self] event in + self?.messageEvents.append(event) + }) + .disposed(by: disposeBag) + } + + private func setupCallWithId(_ callId: String = CallTestConstants.callId) { + let call = CallModel.createTestCall(withCallId: callId) + calls.update { calls in + calls[callId] = call + } + } + + // MARK: - VCard Tests + + func testSendVCard_WithValidData_CallsDBManager() { + let profile = Profile.createTestProfile() + mockDBManager.accountVCardResult = profile + + service.sendVCard(callID: CallTestConstants.callId, accountID: CallTestConstants.accountId) + + let expectation = XCTestExpectation(description: "VCard sent") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 0.2) + + XCTAssertTrue(mockDBManager.accountVCardCalled, "DBManager accountVCard method should be called") + XCTAssertEqual(mockDBManager.accountVCardId, CallTestConstants.accountId, "DBManager accountVCard should be called with correct accountId") + XCTAssertEqual(mockDBManager.accountVCardResult?.uri, CallTestConstants.profileUri, "The correct profile should be retrieved") + } + + func testSendVCard_WithEmptyCallId_DoesNothing() { + service.sendVCard(callID: "", accountID: CallTestConstants.accountId) + + XCTAssertFalse(mockDBManager.accountVCardCalled, "DBManager accountVCard should not be called with empty callId") + } + + func testSendVCard_WithEmptyAccountId_DoesNothing() { + service.sendVCard(callID: CallTestConstants.callId, accountID: "") + + XCTAssertFalse(mockDBManager.accountVCardCalled, "DBManager accountVCard should not be called with empty accountId") + } + + // MARK: - In-Call Message Tests + + func testSendInCallMessage_CallsAdapter() { + setupCallWithId() + let accountModel = AccountModel.createTestAccount() + + service.sendInCallMessage(callID: CallTestConstants.callId, message: CallTestConstants.messageContent, accountId: accountModel) + + XCTAssertTrue(mockCallsAdapter.sendTextMessageCalled, "sendTextMessage should be called") + XCTAssertEqual(mockCallsAdapter.sentTextMessageCallId, CallTestConstants.callId, "Call ID should match") + XCTAssertEqual(mockCallsAdapter.sentTextMessageAccountId, accountModel.id, "Account ID should match") + + XCTAssertEqual(messageEvents.count, 1, "One message event should be created") + + let event = messageEvents.first! + XCTAssertEqual(event.eventType, .newOutgoingMessage, "Event type should be newOutgoingMessage") + + if let content: String = event.getEventInput(.content) { + XCTAssertEqual(content, CallTestConstants.messageContent, "Event content should match the message") + } else { + XCTFail("Event should contain content") + } + + if let accountId: String = event.getEventInput(.accountId) { + XCTAssertEqual(accountId, accountModel.id, "Event account ID should match") + } else { + XCTFail("Event should contain account ID") + } + } + + func testSendInCallMessage_WithInvalidCallID_DoesNothing() { + setupCallWithId() + let accountModel = AccountModel.createTestAccount() + + service.sendInCallMessage(callID: CallTestConstants.invalidCallId, message: CallTestConstants.messageContent, accountId: accountModel) + + XCTAssertFalse(mockCallsAdapter.sendTextMessageCalled, "sendTextMessage should not be called with invalid call ID") + XCTAssertEqual(messageEvents.count, 0, "No message events should be created for invalid call ID") + } + + // MARK: - Incoming Message Tests + + func testHandleIncomingMessage_VCardMessage_PostsNotification() { + setupCallWithId() + let vCardMessage = [TestMIMETypes.vCard: TestConstants.vCardData] + + let expectation = XCTestExpectation(description: "Notification posted") + var notificationReceived = false + let notificationObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name(ProfileNotifications.messageReceived.rawValue), + object: nil, + queue: .main + ) { notification in + notificationReceived = true + XCTAssertEqual(notification.userInfo?[ProfileNotificationsKeys.ringID.rawValue] as? String, CallTestConstants.profileUri, "Ring ID should match fromURI") + XCTAssertEqual(notification.userInfo?[ProfileNotificationsKeys.accountId.rawValue] as? String, CallTestConstants.accountId, "Account ID should match call account ID") + + if let notificationMessage = notification.userInfo?[ProfileNotificationsKeys.message.rawValue] as? [String: String] { + XCTAssertEqual(notificationMessage[TestMIMETypes.vCard], TestConstants.vCardData, "VCard data should be included in notification") + } else { + XCTFail("Notification should include message data") + } + + expectation.fulfill() + } + + service.handleIncomingMessage(callId: CallTestConstants.callId, fromURI: CallTestConstants.profileUri, message: vCardMessage) + + wait(for: [expectation], timeout: 0.1) + XCTAssertTrue(notificationReceived, "Notification should be received") + NotificationCenter.default.removeObserver(notificationObserver) + } + + func testHandleIncomingMessage_TextMessage_SendsEvent() { + setupCallWithId() + let textMessage = [TestMIMETypes.textPlain: CallTestConstants.messageContent] + + service.handleIncomingMessage(callId: CallTestConstants.callId, fromURI: CallTestConstants.profileUri, message: textMessage) + + XCTAssertEqual(messageEvents.count, 1, "One message event should be created") + + if let event = messageEvents.first { + if let content: String = event.getEventInput(.content) { + XCTAssertEqual(content, CallTestConstants.messageContent, "Event content should match the message") + } else { + XCTFail("Event should contain content") + } + + if let name: String = event.getEventInput(.name) { + XCTAssertEqual(name, CallTestConstants.displayName, "Event name should match call display name") + } else { + XCTFail("Event should contain name") + } + + if let peerUri: String = event.getEventInput(.peerUri) { + XCTAssertEqual(peerUri, CallTestConstants.profileUri.filterOutHost(), "Peer URI should match the filtered fromURI") + } else { + XCTFail("Event should contain peer URI") + } + + if let accountId: String = event.getEventInput(.accountId) { + XCTAssertEqual(accountId, CallTestConstants.accountId, "Event account ID should match call account ID") + } else { + XCTFail("Event should contain account ID") + } + + XCTAssertEqual(event.eventType, .newIncomingMessage, "Event type should be newIncomingMessage") + } else { + XCTFail("Event should be created") + } + } + + func testHandleIncomingMessage_WithInvalidCallID_DoesNothing() { + setupCallWithId() + let textMessage = [TestMIMETypes.textPlain: CallTestConstants.messageContent] + + service.handleIncomingMessage(callId: CallTestConstants.invalidCallId, fromURI: CallTestConstants.profileUri, message: textMessage) + + XCTAssertEqual(messageEvents.count, 0, "No events should be created for invalid call ID") + } +} + +// MARK: - Mock Classes + +class MockDBManager: DBManager { + var accountVCardCalled = false + var accountVCardId: String? + var accountVCardResult: Profile? + + override func accountVCard(for accountId: String) -> Profile? { + accountVCardCalled = true + accountVCardId = accountId + return accountVCardResult + } +} diff --git a/Ring/RingTests/RingTests-Bridging-Header.h b/Ring/RingTests/RingTests-Bridging-Header.h index 928c93e01968f765b5c7dbd3e6e8448578070616..10add366e4c361deea3590a63eeadd639b8aca80 100644 --- a/Ring/RingTests/RingTests-Bridging-Header.h +++ b/Ring/RingTests/RingTests-Bridging-Header.h @@ -27,3 +27,4 @@ #import "Ring-Bridging-Header.h" #import "FixtureFailInitDRingAdapter.h" #import "FixtureFailStartDRingAdapter.h" +#import "ObjCMockCallsAdapter.h" diff --git a/Ring/RingTests/TestableModels/ObjCMockCallsAdapter.h b/Ring/RingTests/TestableModels/ObjCMockCallsAdapter.h new file mode 100644 index 0000000000000000000000000000000000000000..99d1121a413a479af853888283528aa0d98cbace --- /dev/null +++ b/Ring/RingTests/TestableModels/ObjCMockCallsAdapter.h @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#import <Foundation/Foundation.h> +#import "CallsAdapter.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * Objective-C implementation of a mock calls adapter for testing + */ +@interface ObjCMockCallsAdapter : CallsAdapter + +// Call details +@property (nonatomic, assign) NSInteger callDetailsCallCount; +@property (nonatomic, copy, nullable) NSString *callDetailsCallId; +@property (nonatomic, copy, nullable) NSString *callDetailsAccountId; +@property (nonatomic, copy, nullable) NSDictionary<NSString*, NSString*> *callDetailsReturnValue; + +// Accept call +@property (nonatomic, assign) NSInteger acceptCallWithIdCount; +@property (nonatomic, copy, nullable) NSString *acceptCallWithIdCallId; +@property (nonatomic, copy, nullable) NSString *acceptCallWithIdAccountId; +@property (nonatomic, copy, nullable) NSArray *acceptCallWithIdMediaList; +@property (nonatomic, assign) BOOL acceptCallReturnValue; + +// Refuse call +@property (nonatomic, assign) NSInteger refuseCallWithIdCount; +@property (nonatomic, copy, nullable) NSString *refuseCallWithIdCallId; +@property (nonatomic, copy, nullable) NSString *refuseCallWithIdAccountId; +@property (nonatomic, assign) BOOL refuseCallReturnValue; + +// Place call +@property (nonatomic, assign) NSInteger placeCallWithAccountIdCount; +@property (nonatomic, copy, nullable) NSString *placeCallWithAccountIdAccountId; +@property (nonatomic, copy, nullable) NSString *placeCallWithAccountIdToParticipantId; +@property (nonatomic, copy, nullable) NSArray *placeCallWithAccountIdMediaList; +@property (nonatomic, copy, nullable) NSString *placeCallReturnValue; + +// Current media list +@property (nonatomic, assign) NSInteger currentMediaListCallCount; +@property (nonatomic, copy, nullable) NSString *currentMediaListCallId; +@property (nonatomic, copy, nullable) NSString *currentMediaListAccountId; +@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString*, NSString*>*> *currentMediaListReturnValue; + +// Answer media change request +@property (nonatomic, assign) NSInteger answerMediaChangeResquestCallCount; +@property (nonatomic, copy, nullable) NSString *answerMediaChangeResquestCallId; +@property (nonatomic, copy, nullable) NSString *answerMediaChangeResquestAccountId; +@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString*, NSString*>*> *answerMediaChangeResquestMedia; + +// For sendTextMessage +@property (nonatomic, assign) BOOL sendTextMessageCalled; +@property (nonatomic, copy, nullable) NSString *sentTextMessageCallId; +@property (nonatomic, copy, nullable) NSString *sentTextMessageAccountId; +@property (nonatomic, copy, nullable) NSDictionary *sentTextMessageMessage; +@property (nonatomic, copy, nullable) NSString *sentTextMessageFrom; +@property (nonatomic, assign) BOOL sentTextMessageIsMixed; + +// Conference management related properties +@property (nonatomic, assign) NSInteger getConferenceCallsCallCount; +@property (nonatomic, copy, nullable) NSString *getConferenceCallsConferenceId; +@property (nonatomic, copy, nullable) NSString *getConferenceCallsAccountId; +@property (nonatomic, copy, nullable) NSArray<NSString*> *getConferenceCallsReturnValue; + +@property (nonatomic, assign) NSInteger getConferenceInfoCallCount; +@property (nonatomic, copy, nullable) NSString *getConferenceInfoConferenceId; +@property (nonatomic, copy, nullable) NSString *getConferenceInfoAccountId; +@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString*, NSString*>*> *getConferenceInfoReturnValue; + +@property (nonatomic, assign) NSInteger getConferenceDetailsCallCount; +@property (nonatomic, copy, nullable) NSString *getConferenceDetailsConferenceId; +@property (nonatomic, copy, nullable) NSString *getConferenceDetailsAccountId; +@property (nonatomic, copy, nullable) NSDictionary<NSString*, NSString*> *getConferenceDetailsReturnValue; + +@property (nonatomic, assign) NSInteger joinConferenceCallCount; +@property (nonatomic, copy, nullable) NSString *joinConferenceConferenceId; +@property (nonatomic, copy, nullable) NSString *joinConferenceCallId; +@property (nonatomic, copy, nullable) NSString *joinConferenceAccountId; +@property (nonatomic, copy, nullable) NSString *joinConferenceAccount2Id; + +@property (nonatomic, assign) NSInteger joinConferencesCallCount; +@property (nonatomic, copy, nullable) NSString *joinConferencesConferenceId; +@property (nonatomic, copy, nullable) NSString *joinConferencesSecondConferenceId; +@property (nonatomic, copy, nullable) NSString *joinConferencesAccountId; +@property (nonatomic, copy, nullable) NSString *joinConferencesAccount2Id; + +@property (nonatomic, assign) NSInteger joinCallCallCount; +@property (nonatomic, copy, nullable) NSString *joinCallFirstCallId; +@property (nonatomic, copy, nullable) NSString *joinCallSecondCallId; +@property (nonatomic, copy, nullable) NSString *joinCallAccountId; +@property (nonatomic, copy, nullable) NSString *joinCallAccount2Id; + +@property (nonatomic, assign) NSInteger hangUpCallCallCount; +@property (nonatomic, copy, nullable) NSString *hangUpCallCallId; +@property (nonatomic, copy, nullable) NSString *hangUpCallAccountId; +@property (nonatomic, assign) BOOL hangUpCallReturnValue; + +@property (nonatomic, assign) NSInteger hangUpConferenceCallCount; +@property (nonatomic, copy, nullable) NSString *hangUpConferenceCallId; +@property (nonatomic, copy, nullable) NSString *hangUpConferenceAccountId; +@property (nonatomic, assign) BOOL hangUpConferenceReturnValue; + +@property (nonatomic, assign) NSInteger setActiveParticipantCallCount; +@property (nonatomic, copy, nullable) NSString *setActiveParticipantJamiId; +@property (nonatomic, copy, nullable) NSString *setActiveParticipantConferenceId; +@property (nonatomic, copy, nullable) NSString *setActiveParticipantAccountId; + +@property (nonatomic, assign) NSInteger setConferenceLayoutCallCount; +@property (nonatomic, assign) int setConferenceLayoutLayout; +@property (nonatomic, copy, nullable) NSString *setConferenceLayoutConferenceId; +@property (nonatomic, copy, nullable) NSString *setConferenceLayoutAccountId; + +@property (nonatomic, assign) NSInteger setConferenceModeratorCallCount; +@property (nonatomic, copy, nullable) NSString *setConferenceModeratorParticipantId; +@property (nonatomic, copy, nullable) NSString *setConferenceModeratorConferenceId; +@property (nonatomic, copy, nullable) NSString *setConferenceModeratorAccountId; +@property (nonatomic, assign) BOOL setConferenceModeratorActive; + +@property (nonatomic, assign) NSInteger hangupConferenceParticipantCallCount; +@property (nonatomic, copy, nullable) NSString *hangupConferenceParticipantParticipantId; +@property (nonatomic, copy, nullable) NSString *hangupConferenceParticipantConferenceId; +@property (nonatomic, copy, nullable) NSString *hangupConferenceParticipantAccountId; +@property (nonatomic, copy, nullable) NSString *hangupConferenceParticipantDeviceId; + +@property (nonatomic, assign) NSInteger muteStreamCallCount; +@property (nonatomic, copy, nullable) NSString *muteStreamParticipantId; +@property (nonatomic, copy, nullable) NSString *muteStreamConferenceId; +@property (nonatomic, copy, nullable) NSString *muteStreamAccountId; +@property (nonatomic, copy, nullable) NSString *muteStreamDeviceId; +@property (nonatomic, copy, nullable) NSString *muteStreamStreamId; +@property (nonatomic, assign) BOOL muteStreamState; + +@property (nonatomic, assign) NSInteger raiseHandCallCount; +@property (nonatomic, copy, nullable) NSString *raiseHandParticipantId; +@property (nonatomic, copy, nullable) NSString *raiseHandConferenceId; +@property (nonatomic, copy, nullable) NSString *raiseHandAccountId; +@property (nonatomic, copy, nullable) NSString *raiseHandDeviceId; +@property (nonatomic, assign) BOOL raiseHandState; + +// Method declarations for Conference Management +- (NSArray<NSString*>*)getConferenceCalls:(NSString*)conferenceId accountId:(NSString*)accountId; +- (NSArray*)getConferenceInfo:(NSString*)conferenceId accountId:(NSString*)accountId; +- (NSDictionary<NSString*, NSString*>*)getConferenceDetails:(NSString*)conferenceId accountId:(NSString*)accountId; +- (BOOL)joinConference:(NSString*)confID call:(NSString*)callID accountId:(NSString*)accountId account2Id:(NSString*)account2Id; +- (BOOL)joinConferences:(NSString*)firstConf secondConference:(NSString*)secondConf accountId:(NSString*)accountId account2Id:(NSString*)account2Id; +- (BOOL)joinCall:(NSString*)firstCall second:(NSString*)secondCall accountId:(NSString*)accountId account2Id:(NSString*)account2Id; +- (BOOL)hangUpCall:(NSString*)callId accountId:(NSString*)accountId; +- (BOOL)hangUpConference:(NSString*)conferenceId accountId:(NSString*)accountId; +- (void)setActiveParticipant:(NSString*)callId forConference:(NSString*)conferenceId accountId:(NSString*)accountId; +- (void)setConferenceLayout:(int)layout forConference:(NSString*)conferenceId accountId:(NSString*)accountId; + +// Method declarations for Participant Management +- (void)setConferenceModerator:(NSString*)participantId forConference:(NSString*)conferenceId accountId:(NSString*)accountId active:(BOOL)isActive; +- (void)hangupConferenceParticipant:(NSString*)participantId forConference:(NSString*)conferenceId accountId:(NSString*)accountId deviceId:(NSString*)deviceId; +-(void)muteStream:(NSString*)participantId + forConference:(NSString*)conferenceId + accountId:(NSString*)accountId + deviceId:(NSString*)deviceId + streamId:(NSString*)streamId + state:(BOOL)state; +-(void)raiseHand:(NSString*)participantId + forConference:(NSString*)conferenceId + accountId:(NSString*)accountId + deviceId:(NSString*)deviceId + state:(BOOL)state; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Ring/RingTests/TestableModels/ObjCMockCallsAdapter.m b/Ring/RingTests/TestableModels/ObjCMockCallsAdapter.m new file mode 100644 index 0000000000000000000000000000000000000000..072f1408e3280cb8156f29e233d9e4797216eefb --- /dev/null +++ b/Ring/RingTests/TestableModels/ObjCMockCallsAdapter.m @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2025-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#import "ObjCMockCallsAdapter.h" + +@implementation ObjCMockCallsAdapter + +- (nullable NSDictionary<NSString *, NSString *> *)callDetailsWithCallId:(NSString *)callId accountId:(NSString *)accountId { + self.callDetailsCallCount++; + self.callDetailsCallId = callId; + self.callDetailsAccountId = accountId; + return self.callDetailsReturnValue; +} + +- (BOOL)acceptCallWithId:(NSString *)callId accountId:(NSString *)accountId withMedia:(NSArray *)mediaList { + self.acceptCallWithIdCount++; + self.acceptCallWithIdCallId = callId; + self.acceptCallWithIdAccountId = accountId; + self.acceptCallWithIdMediaList = mediaList; + return self.acceptCallReturnValue; +} + +- (BOOL)refuseCallWithId:(NSString *)callId accountId:(NSString *)accountId { + self.refuseCallWithIdCount++; + self.refuseCallWithIdCallId = callId; + self.refuseCallWithIdAccountId = accountId; + return self.refuseCallReturnValue; +} + +- (NSString *)placeCallWithAccountId:(NSString *)accountId toParticipantId:(NSString *)participantId withMedia:(NSArray *)mediaList { + self.placeCallWithAccountIdCount++; + self.placeCallWithAccountIdAccountId = accountId; + self.placeCallWithAccountIdToParticipantId = participantId; + self.placeCallWithAccountIdMediaList = mediaList; + return self.placeCallReturnValue; +} + +- (nullable NSArray<NSDictionary<NSString *, NSString *> *> *)currentMediaListWithCallId:(NSString *)callId accountId:(NSString *)accountId { + self.currentMediaListCallCount++; + self.currentMediaListCallId = callId; + self.currentMediaListAccountId = accountId; + return self.currentMediaListReturnValue; +} + +- (void)answerMediaChangeResquest:(NSString *)callId accountId:(NSString *)accountId withMedia:(NSArray *)mediaList { + self.answerMediaChangeResquestCallCount++; + self.answerMediaChangeResquestCallId = callId; + self.answerMediaChangeResquestAccountId = accountId; + self.answerMediaChangeResquestMedia = mediaList; +} + +- (void)sendTextMessageWithCallID:(NSString*)callId accountId:(NSString*)accountId message:(NSDictionary*)message from:(NSString*)jamiId isMixed:(bool)isMixed { + self.sendTextMessageCalled = YES; + self.sentTextMessageCallId = callId; + self.sentTextMessageAccountId = accountId; + self.sentTextMessageMessage = message; + self.sentTextMessageFrom = jamiId; + self.sentTextMessageIsMixed = isMixed; +} + +// Conference Management Method Implementations +- (NSArray<NSString*>*)getConferenceCalls:(NSString*)conferenceId accountId:(NSString*)accountId { + self.getConferenceCallsCallCount++; + self.getConferenceCallsConferenceId = conferenceId; + self.getConferenceCallsAccountId = accountId; + return self.getConferenceCallsReturnValue ?: @[]; +} + +- (NSArray*)getConferenceInfo:(NSString*)conferenceId accountId:(NSString*)accountId { + self.getConferenceInfoCallCount++; + self.getConferenceInfoConferenceId = conferenceId; + self.getConferenceInfoAccountId = accountId; + return self.getConferenceInfoReturnValue ?: @[]; +} + +- (NSDictionary<NSString*, NSString*>*)getConferenceDetails:(NSString*)conferenceId accountId:(NSString*)accountId { + self.getConferenceDetailsCallCount++; + self.getConferenceDetailsConferenceId = conferenceId; + self.getConferenceDetailsAccountId = accountId; + return self.getConferenceDetailsReturnValue ?: @{}; +} + +- (BOOL)joinConference:(NSString*)confID call:(NSString*)callID accountId:(NSString*)accountId account2Id:(NSString*)account2Id { + self.joinConferenceCallCount++; + self.joinConferenceConferenceId = confID; + self.joinConferenceCallId = callID; + self.joinConferenceAccountId = accountId; + self.joinConferenceAccount2Id = account2Id; + return YES; +} + +- (BOOL)joinConferences:(NSString*)firstConf secondConference:(NSString*)secondConf accountId:(NSString*)accountId account2Id:(NSString*)account2Id { + self.joinConferencesCallCount++; + self.joinConferencesConferenceId = firstConf; + self.joinConferencesSecondConferenceId = secondConf; + self.joinConferencesAccountId = accountId; + self.joinConferencesAccount2Id = account2Id; + return YES; +} + +- (BOOL)joinCall:(NSString*)firstCall second:(NSString*)secondCall accountId:(NSString*)accountId account2Id:(NSString*)account2Id { + self.joinCallCallCount++; + self.joinCallFirstCallId = firstCall; + self.joinCallSecondCallId = secondCall; + self.joinCallAccountId = accountId; + self.joinCallAccount2Id = account2Id; + return YES; +} + +- (BOOL)hangUpCall:(NSString*)callId accountId:(NSString*)accountId { + self.hangUpCallCallCount++; + self.hangUpCallCallId = callId; + self.hangUpCallAccountId = accountId; + return self.hangUpCallReturnValue; +} + +- (BOOL)hangUpConference:(NSString*)conferenceId accountId:(NSString*)accountId { + self.hangUpConferenceCallCount++; + self.hangUpConferenceCallId = conferenceId; + self.hangUpConferenceAccountId = accountId; + return self.hangUpConferenceReturnValue; +} + +- (void)setActiveParticipant:(NSString*)callId forConference:(NSString*)conferenceId accountId:(NSString*)accountId { + self.setActiveParticipantCallCount++; + self.setActiveParticipantJamiId = callId; + self.setActiveParticipantConferenceId = conferenceId; + self.setActiveParticipantAccountId = accountId; +} + +- (void)setConferenceLayout:(int)layout forConference:(NSString*)conferenceId accountId:(NSString*)accountId { + self.setConferenceLayoutCallCount++; + self.setConferenceLayoutLayout = layout; + self.setConferenceLayoutConferenceId = conferenceId; + self.setConferenceLayoutAccountId = accountId; +} + +- (void)setConferenceModerator:(NSString*)participantId forConference:(NSString*)conferenceId accountId:(NSString*)accountId active:(BOOL)isActive { + self.setConferenceModeratorCallCount++; + self.setConferenceModeratorParticipantId = participantId; + self.setConferenceModeratorConferenceId = conferenceId; + self.setConferenceModeratorAccountId = accountId; + self.setConferenceModeratorActive = isActive; +} + +- (void)hangupConferenceParticipant:(NSString*)participantId forConference:(NSString*)conferenceId accountId:(NSString*)accountId deviceId:(NSString*)deviceId { + self.hangupConferenceParticipantCallCount++; + self.hangupConferenceParticipantParticipantId = participantId; + self.hangupConferenceParticipantConferenceId = conferenceId; + self.hangupConferenceParticipantAccountId = accountId; + self.hangupConferenceParticipantDeviceId = deviceId; +} + +-(void)muteStream:(NSString*)participantId + forConference:(NSString*)conferenceId + accountId:(NSString*)accountId + deviceId:(NSString*)deviceId + streamId:(NSString*)streamId + state:(BOOL)state { + self.muteStreamCallCount++; + self.muteStreamParticipantId = participantId; + self.muteStreamConferenceId = conferenceId; + self.muteStreamAccountId = accountId; + self.muteStreamDeviceId = deviceId; + self.muteStreamStreamId = streamId; + self.muteStreamState = state; +} + +-(void)raiseHand:(NSString*)participantId + forConference:(NSString*)conferenceId + accountId:(NSString*)accountId + deviceId:(NSString*)deviceId + state:(BOOL)state { + self.raiseHandCallCount++; + self.raiseHandParticipantId = participantId; + self.raiseHandConferenceId = conferenceId; + self.raiseHandAccountId = accountId; + self.raiseHandDeviceId = deviceId; + self.raiseHandState = state; +} + +@end