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