diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 506023655..7c8004c67 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -138,6 +138,7 @@ 141F3175AFD0AB44D77FFE46 /* test_voice_message.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 166EE9FAC689FDA7A194C29C /* test_voice_message.m4a */; }; 1471A080552631358D152C18 /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BDB3E65A79779EDA5D33D8A /* AudioPlayerState.swift */; }; 149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */; }; + 14A948F9DC5994961F7179B6 /* NativeCallStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956F0BFAC003151CECBBFE45 /* NativeCallStack.swift */; }; 14E99D27628B1A6F0CB46FEA /* SeparatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */; }; 151D2477F75782C8702F2873 /* PollInteractionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC528B3764E3CF7FCFEF40E7 /* PollInteractionHandler.swift */; }; 1524AAAE9BCAA32D65E97866 /* MapLibreInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 842F63AA68E8EB410B1BA29F /* MapLibreInterface.framework */; }; @@ -286,6 +287,7 @@ 2C289BECCCBEDBA48DC9AC67 /* ClassicAppMXAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74240BE69DACAD01AA670730 /* ClassicAppMXAccount.swift */; }; 2C4C750D0039AFABDF24236C /* TemplateScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 342BEBC3C5FC3F9943C41C4C /* TemplateScreenViewModelProtocol.swift */; }; 2C5E832434EE94E21AB3B238 /* EmojiPickerScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3EAE3E9D5EF4A6D5D9C6CFD /* EmojiPickerScreenViewModel.swift */; }; + 2C8926703BDDD3ECF2AC028D /* NativeCallRoomContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F288457CD4C821DF48C1B6EE /* NativeCallRoomContext.swift */; }; 2CA61BB208CD82EBDB58CD13 /* VideoRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED0CBEAB5F796BEFBAF7BB6A /* VideoRoomTimelineView.swift */; }; 2CA6ABBC9A88EB89EA52FCCB /* ConfettiScene.scn in Resources */ = {isa = PBXBuildFile; fileRef = B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */; }; 2CC3F27CD76DB00A747BEA6C /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD5523BDEDB247E29228476 /* AppSettings.swift */; }; @@ -295,6 +297,7 @@ 2D38D39B1789B91AE69F477F /* PhotoLibraryManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD955A0380C287C418F1A74D /* PhotoLibraryManagerMock.swift */; }; 2D45A04699BB6BA3B3A0CB9A /* TracingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */; }; 2D76463CE5A9238B5BB5F393 /* TimelineItemKeyForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F137B69DCB59353E37B80 /* TimelineItemKeyForwarder.swift */; }; + 2D77236B66808B661731BC07 /* NativeCallScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55421139EC9E8C2D8218AC57 /* NativeCallScreenCoordinator.swift */; }; 2DA27D78560D5F79B917E163 /* AudioConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44E35AA87F49503E7B3BF6E /* AudioConverter.swift */; }; 2DD9D0FE7CB5CFC80D071451 /* AppLockScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3E67E09FE5A35D73818C39 /* AppLockScreenModels.swift */; }; 2E073ABFC6062D1DF727E2BC /* preview_avatar_room.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 690915157A874D1F21ADE70C /* preview_avatar_room.jpg */; }; @@ -344,8 +347,8 @@ 36926D795D6D19177C7812F8 /* EncryptionResetPasswordScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6935A55AB3B0C94BC566DD6 /* EncryptionResetPasswordScreenCoordinator.swift */; }; 369BF960E52BBEE61F8A5BD1 /* BlockedUsersScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED60E4D2CD678E1EBF16F77A /* BlockedUsersScreen.swift */; }; 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */; }; - 36AD4DD4C798E22584ED3200 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 997C7385E1A07E061D7E2100 /* GZIP */; }; - 36CD6E11B37396E14F032CB6 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 7731767AE437BA3BD2CC14A8 /* Sentry */; }; + 36AD4DD4C798E22584ED3200 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 9573B94B1C86C6DF751AF3FD /* SwiftState */; }; + 36CD6E11B37396E14F032CB6 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 997C7385E1A07E061D7E2100 /* GZIP */; }; 36DCA11EA7320A45A4CC8D07 /* SpaceRemoveChildrenConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44AC61A375CC2E47938B5D12 /* SpaceRemoveChildrenConfirmationView.swift */; }; 36DE961B784087D5E18EF9BA /* LogViewerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A07692536D66E3DA32C4964 /* LogViewerScreen.swift */; }; 36FDE74D301AF4A08318B96D /* preview_video.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 369236171C351AB8C70BB161 /* preview_video.jpg */; }; @@ -353,7 +356,7 @@ 377980ABF16525114E72DDE2 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D2330417C6BE2F6872FFF96B /* SwiftSoup */; }; 37906355E207DB5703754675 /* AppLockSetupBiometricsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F893F4A111CB7BA5C96949 /* AppLockSetupBiometricsScreenViewModel.swift */; }; 37D789F24199B32E3FD1AA7B /* FileRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */; }; - 37E47F5101C0C036289D3807 /* WysiwygComposer in Frameworks */ = {isa = PBXBuildFile; productRef = CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */; }; + 37E47F5101C0C036289D3807 /* Emojibase in Frameworks */ = {isa = PBXBuildFile; productRef = C05729B1684C331F5FFE9232 /* Emojibase */; }; 383063A7924F06D54BA9B24C /* SpaceSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9475FD81B13D50103E5290EB /* SpaceSettingsScreen.swift */; }; 384D6B9A7DFD7260139D6852 /* UITestsNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBEB8D9F4940E161B18FE4BC /* UITestsNotificationCenter.swift */; }; 38546A6010A2CF240EC9AF73 /* BindableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */; }; @@ -425,11 +428,11 @@ 446BCD2D0AE27E0CFD1BDC8F /* ImageMediaEventsTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF584D757E768EA7776A532 /* ImageMediaEventsTimelineView.swift */; }; 44BDD670FF9095ACE240A3A2 /* VoiceMessageMediaManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */; }; 44D3EA8583C54BA3D8058499 /* SyncServiceSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DBBBC78047C669166C9F7C /* SyncServiceSDKMock.swift */; }; - 44F0E1B576C7599DF8022071 /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = A05AF81DDD14AD58CB0E1B9B /* Version */; }; + 44F0E1B576C7599DF8022071 /* SentrySwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 75361A9D8A3C5501EADB225D /* SentrySwiftUI */; }; 454311EAC17D778E19F46592 /* NotificationPermissionsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91868EB98818044E6FEBE532 /* NotificationPermissionsScreenCoordinator.swift */; }; 454F8DDC4442C0DE54094902 /* LABiometryType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F219838588C62198E726E3 /* LABiometryType.swift */; }; 45D6DC594816288983627484 /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; }; - 4610C57A4785FFF5E67F0C6D /* SwiftOGG in Frameworks */ = {isa = PBXBuildFile; productRef = 391D11F92DFC91666AA1503F /* SwiftOGG */; }; + 4610C57A4785FFF5E67F0C6D /* WysiwygComposer in Frameworks */ = {isa = PBXBuildFile; productRef = CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */; }; 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; }; 4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */; }; 46A183C6125A669AEB005699 /* UserProfileScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F134D2D91DFF732FB75B2CB7 /* UserProfileScreenViewModelProtocol.swift */; }; @@ -462,6 +465,7 @@ 4A618590DEB72C4F186BFED4 /* UserSessionFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99FDEEB71173C4C6FA2734C /* UserSessionFlowCoordinator.swift */; }; 4A8287E5281B44A8754BE509 /* SessionVerificationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED33988DA4FD4FC666800106 /* SessionVerificationScreenViewModel.swift */; }; 4A85928E27D4C1A548A06EE9 /* StartChatScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052B2F924572AFD70B5F500E /* StartChatScreenViewModel.swift */; }; + 4A9786829B79BD13EF1626C1 /* NativeCallSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2316BADD6C1119C1FA229 /* NativeCallSystem.swift */; }; 4A9CEEE612D6D8B3DDBD28BA /* RoomListFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24EC819497BB5F8C4998D760 /* RoomListFilterView.swift */; }; 4AAA8606FBA290E23D15422E /* AvatarHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC743C7A85E3171BCBF0A653 /* AvatarHeaderView.swift */; }; 4AC0B3634BDA6817EC7DF539 /* ClientFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 996AF8CF3FCF19B62F005C57 /* ClientFactory.swift */; }; @@ -494,6 +498,7 @@ 4E9782D683660463804C2DC3 /* UserIdentityProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F793C422BDACE0C60C774F4 /* UserIdentityProxyProtocol.swift */; }; 4E9ED3E6E979C6C7FEC1D011 /* AnalyticsConsentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353024006CB726E9F9187B3A /* AnalyticsConsentState.swift */; }; 4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B564D748B67A156F413CD97 /* NotificationSettingsEditScreenModels.swift */; }; + 4EAC1586FF01F6AB8B8D59F9 /* CXCallControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6943C2B0925AB90ECAFE59F /* CXCallControllerProtocol.swift */; }; 4EAC427267424192964B16B3 /* AppSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13BE9781699FB510E9263192 /* AppSettingsHook.swift */; }; 4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */; }; 4EF76B21E1128FA18F8FB7A9 /* SearchBreadcrumb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095B8ED0CA87F4A0A4858719 /* SearchBreadcrumb.swift */; }; @@ -733,7 +738,7 @@ 74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */; }; 7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */; }; 75072C529C9C363F531721F1 /* RecoveryKeyScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE97AD18893E2D8120F559D /* RecoveryKeyScreenHook.swift */; }; - 754602A7B2AAD443C4228ED4 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; }; + 754602A7B2AAD443C4228ED4 /* ElementCall in Frameworks */ = {isa = PBXBuildFile; productRef = 99570A04BD7A395538321E94 /* ElementCall */; }; 755395927DDD6EBDDA5E217A /* SettingsFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */; }; 755727E0B756430DFFEC4732 /* SessionVerificationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */; }; 756BD0B80C1AF2A6DF0786CB /* CommonSettings+NotificationName.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */; }; @@ -850,6 +855,7 @@ 86675910612A12409262DFBD /* SessionVerificationStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */; }; 866FA35E7A2339EF8B6D91CA /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C6B6FFCE5B28AA03DD46F46 /* LinkPreviewView.swift */; }; 86769B62BAE17601B3AE1B60 /* TimelineMediaPreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */; }; + 868C8DDDAF80086B0C376FFF /* NativeCallStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A84AEC7D5644B3F83B5333 /* NativeCallStyle.swift */; }; 8691186F9B99BCDDB7CACDD8 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */; }; 8696659B8905EE4693B96EAA /* RemoveItemButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95547A6C69606B5282C765D3 /* RemoveItemButton.swift */; }; 86DFA58FBBEB0AF671D2A1E1 /* HomeScreenKnockedCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103580EBA06155B1343EF16 /* HomeScreenKnockedCell.swift */; }; @@ -947,6 +953,7 @@ 93BA4A81B6D893271101F9F0 /* AnalyticsEvents in Frameworks */ = {isa = PBXBuildFile; productRef = 2A3F7BCCB18C15B30CCA39A9 /* AnalyticsEvents */; }; 93BAF04D9CCBC0A8841414D0 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D67E616BCA82D8A1258D488 /* NetworkMonitor.swift */; }; 93DC8297B8287B50B2A4B57D /* ExpiringTaskRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B25F959A434BB9923A3223F /* ExpiringTaskRunner.swift */; }; + 93DED70DA65F7B6889D60CD8 /* CXCallControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E858B68153E2C6E5800336AB /* CXCallControllerMock.swift */; }; 9408CE8B8865C0C8DD4C9869 /* NoticeRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD51B4D5173F7FC886F5360 /* NoticeRoomTimelineItemContent.swift */; }; 9415ED7F6200904F2E179EF9 /* RoomListSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B8AA3199BAAD066E36CB07 /* RoomListSDKMock.swift */; }; 944EAB7BC2408B2C6570E722 /* AppHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E4AB573FAEBB7B853DD04C /* AppHooks.swift */; }; @@ -1027,7 +1034,7 @@ A076E0A9338FD2D950C3C4A1 /* ChatsSpaceFiltersScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63072981793CCA84EE12798 /* ChatsSpaceFiltersScreenViewModelProtocol.swift */; }; A0861B727B273B5B3DD7FBF6 /* KnockRequestsListScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09227E671DB30795C43FFFD /* KnockRequestsListScreenViewModel.swift */; }; A0868BDE84D2140A885BE3C9 /* EncryptionResetScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8562F4D7DE073BC32902AB /* EncryptionResetScreenViewModelProtocol.swift */; }; - A0D7E5BD0298A97DCBDCE40B /* SentrySwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 75361A9D8A3C5501EADB225D /* SentrySwiftUI */; }; + A0D7E5BD0298A97DCBDCE40B /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 7731767AE437BA3BD2CC14A8 /* Sentry */; }; A10D6CCDE2010C09EEA1A593 /* HomeScreenRoomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7661EFFCAA307A97D71132A /* HomeScreenRoomList.swift */; }; A14A9419105A1CD42F0511C4 /* UserIndicatorModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */; }; A17FAD2EBC53E17B5FD384DB /* InviteUsersScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22730A30C50AC2E3D5BA8642 /* InviteUsersScreenViewModelProtocol.swift */; }; @@ -1080,12 +1087,12 @@ A808DC3F72D15C6C5A52317E /* TimelineItemDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDA016D05107DED3B9495CB /* TimelineItemDebugView.swift */; }; A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.swift */; }; A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */; }; - A88328D7E17F73AB64501B51 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = EDFB92E97D9AC4BA8540C18C /* SwiftSoup */; }; + A88328D7E17F73AB64501B51 /* SwiftOGG in Frameworks */ = {isa = PBXBuildFile; productRef = 391D11F92DFC91666AA1503F /* SwiftOGG */; }; A8DB299C5C891EDAE74A22F4 /* UserLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7482D5D1526E3399B00174 /* UserLocationCell.swift */; }; A8E324E700E596E36B0A311B /* BootDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F054DE7D47849687662C9D9 /* BootDetectionManager.swift */; }; A8FA7671948E3DF27F320026 /* BugReportFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */; }; A91D125414C3D9ABBABCF2F1 /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = 6690850AA47ECED7E1CAB345 /* KZFileWatchers */; }; - A93661C962B12942C08864B6 /* Emojibase in Frameworks */ = {isa = PBXBuildFile; productRef = C05729B1684C331F5FFE9232 /* Emojibase */; }; + A93661C962B12942C08864B6 /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = A05AF81DDD14AD58CB0E1B9B /* Version */; }; A950C95855C474F75B30CA7B /* PollFormScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDAB580109C09A6AA97AF7E /* PollFormScreenTests.swift */; }; A96013671CAEFDE868C7E0D8 /* RedactConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC720B8D99D1490CCDBED6CE /* RedactConfirmationView.swift */; }; A969147E0EEE0E27EE226570 /* MediaUploadPreviewScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F29139BC2A804CE5E0757E /* MediaUploadPreviewScreenViewModel.swift */; }; @@ -1120,7 +1127,7 @@ AFE2AB612A1460E49578D746 /* JoinRoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDCCD2F6B405C14B9BCE94E /* JoinRoomScreenCoordinator.swift */; }; B004A08BE32F06832BF41CFC /* RoomDetailsScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3674212CAB6C5143DD437AC4 /* RoomDetailsScreenHook.swift */; }; B04E9EB589CE99C3929E817A /* HomeScreenRecoveryKeyConfirmationBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05512FB13987D221B7205DE0 /* HomeScreenRecoveryKeyConfirmationBanner.swift */; }; - B0CB16349B96262AA65A04AF /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 9573B94B1C86C6DF751AF3FD /* SwiftState */; }; + B0CB16349B96262AA65A04AF /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; }; B1088417801A962BA861C13F /* RoomPowerLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E247E29C7A0E421DB839D7 /* RoomPowerLevel.swift */; }; B10F7D5C237417DA160F4603 /* LongPressWithFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9F148717D74F73BE724434 /* LongPressWithFeedback.swift */; }; B13774779EA19FDD7A35A4A8 /* RoomRolesAndPermissionsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C28B70BEFD3676F11D5D51F /* RoomRolesAndPermissionsScreenCoordinator.swift */; }; @@ -1271,6 +1278,7 @@ C9F5B48D15B9BCAE1F8D564E /* RoomNotificationModeProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1511766C534367700C8DD75 /* RoomNotificationModeProxy.swift */; }; CA12AE0DCD57D49CD96C699A /* WaveformCursorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */; }; CA2BFC5504AD91A028CFA45B /* AnalyticsConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA2397174D0DC3918A7A8A7B /* AnalyticsConfiguration.swift */; }; + CAEE1AF71E8C13756D572CF4 /* DSWaveformImageViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2A4106A0A96DC4C273128AA5 /* DSWaveformImageViews */; }; CB07184D37D5D65327A5A693 /* AuthenticationFlowCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F46CC4FD89ECF4CF26391A /* AuthenticationFlowCoordinatorTests.swift */; }; CB137BFB3E083C33E398A6CB /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = A7CA6F33C553805035C3B114 /* DeviceKit */; }; CB498F4E27AA0545DCEF0F6F /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 78A5A8DE1E2B09C978C7F3B0 /* KeychainAccess */; }; @@ -1431,14 +1439,14 @@ E4B07FF075C99D04D9AF792D /* AppLockSetupPINScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B410B32B72C90BF94E481F33 /* AppLockSetupPINScreenModels.swift */; }; E4D261E237D5D45E6DF2D0F1 /* ManageRoomMemberSheetModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787E84119E626E2F0E0BFBE8 /* ManageRoomMemberSheetModels.swift */; }; E52F1E00FBAABA703DA510F9 /* ContentScannerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDFCC7391761D4DB2CC0D97 /* ContentScannerService.swift */; }; - E587A513B3E1EE9B56E9AB9C /* DSWaveformImageViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2A4106A0A96DC4C273128AA5 /* DSWaveformImageViews */; }; + E587A513B3E1EE9B56E9AB9C /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 4D7E6BFC89715FC3CF0349D0 /* Flow */; }; E58F1F3276E98A93F7D39219 /* RoomPollsHistoryScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D8479BB704B7EF696F8ABE /* RoomPollsHistoryScreenCoordinator.swift */; }; E591742E509A2A009BF25F9D /* RoomEventStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AE5800184E93CD5E02C6543 /* RoomEventStringBuilderTests.swift */; }; E593841CEBFDDC5387A75EBA /* NotificationToneManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D148332A613AD27E9A2896C9 /* NotificationToneManagerTests.swift */; }; E5AB28123E2488F97E953AC0 /* CallNotificationRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED17433ADC77287F8904F9 /* CallNotificationRoomTimelineItem.swift */; }; E5E43A0CA99AF5BA11B194A2 /* EncryptionSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AD14D3ADADE8F6A10F9E88 /* EncryptionSettingsTests.swift */; }; E5F4C992845388B50BABACAA /* ServerSelectionScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB8BC4C791D0E88CFCF4E5DF /* ServerSelectionScreenCoordinator.swift */; }; - E673A5442A9A912DE6CE097A /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 4D7E6BFC89715FC3CF0349D0 /* Flow */; }; + E673A5442A9A912DE6CE097A /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = EDFB92E97D9AC4BA8540C18C /* SwiftSoup */; }; E67418DACEDBC29E988E6ACD /* message.caf in Resources */ = {isa = PBXBuildFile; fileRef = ED482057AE39D5C6D9C5F3D8 /* message.caf */; }; E6A2F6E4795C1054025AACFF /* NotificationItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3AB32690C6CE5C62A86D6FA /* NotificationItemProxy.swift */; }; E6FA87F773424B27614B23E9 /* TimelineItemAccessibilityModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7E93C2E148B96EF6A8500 /* TimelineItemAccessibilityModifier.swift */; }; @@ -1588,6 +1596,7 @@ FB595EC9C00AB32F39034055 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A37E2FACFD041CE466223CD /* SceneDelegate.swift */; }; FBD402E3170EB1ED0D1AA672 /* EncryptionKeyProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2355398E4A55DA5A89128AD1 /* EncryptionKeyProvider.swift */; }; FBF09B6C900415800DDF2A21 /* EmojiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C113E0CB7E15E9765B1817A /* EmojiProvider.swift */; }; + FBFFABBDCDEC3C0C03430E19 /* NativeCallStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D4EE9E5F3E5BF651869242 /* NativeCallStackTests.swift */; }; FC10228E73323BDC09526F97 /* LRUCache in Frameworks */ = {isa = PBXBuildFile; productRef = 1081D3630AAD3ACEDDEC3A98 /* LRUCache */; }; FC31493979ED1FDF7D5EA3F9 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */; }; FCD3F2B82CAB29A07887A127 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DE8DC9B3FBA402117DC4C49F /* Kingfisher */; }; @@ -1756,6 +1765,7 @@ 0287793F11C480E242B03DF5 /* UserDiscoveryServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoveryServiceTest.swift; sourceTree = ""; }; 028CAFDBB60E2DBB24C3F621 /* landscape_test_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = landscape_test_image.jpg; sourceTree = ""; }; 029D5701F80A9AF7167BB4D0 /* TimelineModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineModels.swift; sourceTree = ""; }; + 02A84AEC7D5644B3F83B5333 /* NativeCallStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCallStyle.swift; sourceTree = ""; }; 02D155E09BF961BBA8F85263 /* InviteUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenViewModel.swift; sourceTree = ""; }; 02EE0FABA8ED6D6C1D6CE71D /* ReactionsSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsSummaryView.swift; sourceTree = ""; }; 0315C328FF40F84276364E66 /* SecurityAndPrivacyScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityAndPrivacyScreenViewModelTests.swift; sourceTree = ""; }; @@ -2255,6 +2265,7 @@ 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineItem.swift; sourceTree = ""; }; 536E72DCBEEC4A1FE66CFDCE /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; 53C65A839BC897F6C236E8A5 /* TimelineInteractionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineInteractionHandlerTests.swift; sourceTree = ""; }; + 53D4EE9E5F3E5BF651869242 /* NativeCallStackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCallStackTests.swift; sourceTree = ""; }; 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModel.swift; sourceTree = ""; }; 5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModelTests.swift; sourceTree = ""; }; 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; @@ -2264,6 +2275,7 @@ 54A7923F0115CF17ABC8047F /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/SAS.strings; sourceTree = ""; }; 54AD70D6E03D2031AE1B5A52 /* TimelineReactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReactionsView.swift; sourceTree = ""; }; 54C4E7B46099462F12000C91 /* DeveloperOptionsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenViewModelProtocol.swift; sourceTree = ""; }; + 55421139EC9E8C2D8218AC57 /* NativeCallScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCallScreenCoordinator.swift; sourceTree = ""; }; 5557DDA438841AF5DC003D0B /* SpaceListScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceListScreenViewModelTests.swift; sourceTree = ""; }; 558AA5B376CE8C3982A9557F /* AnalyticsServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsServiceProtocol.swift; sourceTree = ""; }; 55AEEF8142DF1B59DB40FB93 /* TimelineItemSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemSender.swift; sourceTree = ""; }; @@ -2633,6 +2645,7 @@ 951F277E0585E50AC91987C8 /* DeclineAndBlockScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenViewModelProtocol.swift; sourceTree = ""; }; 955336CBD5ED73C792D1F580 /* EncryptionAuthenticity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionAuthenticity.swift; sourceTree = ""; }; 95547A6C69606B5282C765D3 /* RemoveItemButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveItemButton.swift; sourceTree = ""; }; + 956F0BFAC003151CECBBFE45 /* NativeCallStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCallStack.swift; sourceTree = ""; }; 95A2E4BD7C0CAD25EF924A4C /* GeneratedPreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedPreviewTests.swift; sourceTree = ""; }; 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSRegularExpresion.swift; sourceTree = ""; }; 964093C7CA8823CAB7FFD88E /* UserIdentityProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentityProxy.swift; sourceTree = ""; }; @@ -2913,6 +2926,7 @@ C3285BD95B564CA2A948E511 /* OnboardingFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFlowCoordinator.swift; sourceTree = ""; }; C33B3F17996DFDF5F0181512 /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; C352359663A0E52BA20761EE /* LoadableImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableImage.swift; sourceTree = ""; }; + C3C2316BADD6C1119C1FA229 /* NativeCallSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCallSystem.swift; sourceTree = ""; }; C454F35D8884C0A952FF2F84 /* LiveLocationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSheet.swift; sourceTree = ""; }; C4756240773D26AB74C22668 /* OrientationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrientationManagerProtocol.swift; sourceTree = ""; }; C4C1C19A4BE46EDE1411ECCE /* ThreadTimelineScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -3115,6 +3129,7 @@ E6372DD10DED30E7AD7BCE21 /* RoomListFiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFiltersView.swift; sourceTree = ""; }; E66763BD54A3A1D9C6E6F2F1 /* PinnedItemsIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedItemsIndicatorView.swift; sourceTree = ""; }; E6935A55AB3B0C94BC566DD6 /* EncryptionResetPasswordScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreenCoordinator.swift; sourceTree = ""; }; + E6943C2B0925AB90ECAFE59F /* CXCallControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXCallControllerProtocol.swift; sourceTree = ""; }; E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineItem.swift; sourceTree = ""; }; E6F5D66F158A6662F953733E /* NotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxy.swift; sourceTree = ""; }; E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryManager.swift; sourceTree = ""; }; @@ -3123,6 +3138,7 @@ E7C3B926E1BBC7569AC6C682 /* TimelineSelectionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSelectionToolbar.swift; sourceTree = ""; }; E8294DB9E95C0C0630418466 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; E8495F37D6245AD0CFA1F60B /* AppLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockTests.swift; sourceTree = ""; }; + E858B68153E2C6E5800336AB /* CXCallControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXCallControllerMock.swift; sourceTree = ""; }; E8A1F98AE670377B20679FF5 /* MediaPlayerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProvider.swift; sourceTree = ""; }; E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = ""; }; E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelsProxyMock.swift; sourceTree = ""; }; @@ -3190,6 +3206,7 @@ F1B8500C152BC59445647DA8 /* UnsupportedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedRoomTimelineItem.swift; sourceTree = ""; }; F229480685F30BCB96C439EC /* AdvancedSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreen.swift; sourceTree = ""; }; F276F31C1AEC19E52B951B62 /* SendInviteConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendInviteConfirmationView.swift; sourceTree = ""; }; + F288457CD4C821DF48C1B6EE /* NativeCallRoomContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCallRoomContext.swift; sourceTree = ""; }; F2B94F1B0B5D9D42B15AA6E8 /* ChatsTabFlowCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsTabFlowCoordinatorStateMachine.swift; sourceTree = ""; }; F2DC502B1A566E99969D34DD /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; F2E4EF80DFB8FE7C4469B15D /* RoomDirectorySearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreen.swift; sourceTree = ""; }; @@ -3384,18 +3401,19 @@ FC10228E73323BDC09526F97 /* LRUCache in Frameworks */, EAC6FE2CD4F50A43068ADCD8 /* Mantis in Frameworks */, 76D4D5312ED8E42B35C29FA3 /* MapLibreInterface.framework in Frameworks */, - 754602A7B2AAD443C4228ED4 /* PostHog in Frameworks */, - B0CB16349B96262AA65A04AF /* SwiftState in Frameworks */, - 36AD4DD4C798E22584ED3200 /* GZIP in Frameworks */, - 36CD6E11B37396E14F032CB6 /* Sentry in Frameworks */, - A0D7E5BD0298A97DCBDCE40B /* SentrySwiftUI in Frameworks */, - 44F0E1B576C7599DF8022071 /* Version in Frameworks */, - A93661C962B12942C08864B6 /* Emojibase in Frameworks */, - 37E47F5101C0C036289D3807 /* WysiwygComposer in Frameworks */, - 4610C57A4785FFF5E67F0C6D /* SwiftOGG in Frameworks */, - A88328D7E17F73AB64501B51 /* SwiftSoup in Frameworks */, - E673A5442A9A912DE6CE097A /* Flow in Frameworks */, - E587A513B3E1EE9B56E9AB9C /* DSWaveformImageViews in Frameworks */, + 754602A7B2AAD443C4228ED4 /* ElementCall in Frameworks */, + B0CB16349B96262AA65A04AF /* PostHog in Frameworks */, + 36AD4DD4C798E22584ED3200 /* SwiftState in Frameworks */, + 36CD6E11B37396E14F032CB6 /* GZIP in Frameworks */, + A0D7E5BD0298A97DCBDCE40B /* Sentry in Frameworks */, + 44F0E1B576C7599DF8022071 /* SentrySwiftUI in Frameworks */, + A93661C962B12942C08864B6 /* Version in Frameworks */, + 37E47F5101C0C036289D3807 /* Emojibase in Frameworks */, + 4610C57A4785FFF5E67F0C6D /* WysiwygComposer in Frameworks */, + A88328D7E17F73AB64501B51 /* SwiftOGG in Frameworks */, + E673A5442A9A912DE6CE097A /* SwiftSoup in Frameworks */, + E587A513B3E1EE9B56E9AB9C /* Flow in Frameworks */, + CAEE1AF71E8C13756D572CF4 /* DSWaveformImageViews in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3527,6 +3545,7 @@ 90FD376E9373246E4925CE95 /* Location */, 79E560F5113ED25D172E550C /* Media */, 6709362D60732DED2069AE0F /* MediaPlayer */, + 3331AF1BFF699CE25B734F9D /* NativeCall */, 6DE13A7AE6587B079F4049D7 /* Notification */, 114DC16B28140F885FD833E2 /* NotificationSettings */, 599DFFE0805B08454E40D64A /* Polls */, @@ -4039,6 +4058,7 @@ C723327DC4A3093CD9675B27 /* CLLocationManagerMock.swift */, 4E600B315B920B9687F8EE1B /* ComposerDraftServiceMock.swift */, EC446234C8E5031F36F1BBE2 /* ContentScannerServiceMock.swift */, + E858B68153E2C6E5800336AB /* CXCallControllerMock.swift */, 86E1BAA7232081635662A83F /* CXProviderMock.swift */, E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */, 3A21027F05874B1BCC3E452B /* InvitedRoomProxyMock.swift */, @@ -4146,6 +4166,17 @@ path = ChatsSpaceFiltersScreen; sourceTree = ""; }; + 3331AF1BFF699CE25B734F9D /* NativeCall */ = { + isa = PBXGroup; + children = ( + F288457CD4C821DF48C1B6EE /* NativeCallRoomContext.swift */, + 956F0BFAC003151CECBBFE45 /* NativeCallStack.swift */, + 02A84AEC7D5644B3F83B5333 /* NativeCallStyle.swift */, + C3C2316BADD6C1119C1FA229 /* NativeCallSystem.swift */, + ); + path = NativeCall; + sourceTree = ""; + }; 3348D14DBDB54E72FC67E2F3 /* MessageForwardingScreen */ = { isa = PBXGroup; children = ( @@ -5249,6 +5280,7 @@ AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */, 03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */, 6F6E6EDC4BBF962B2ED595A4 /* MessageForwardingScreenViewModelTests.swift */, + 53D4EE9E5F3E5BF651869242 /* NativeCallStackTests.swift */, F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */, 78913D6E120D46138E97C107 /* NavigationSplitCoordinatorTests.swift */, 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */, @@ -5870,6 +5902,7 @@ 92E99C57D7F92ED16F73282C /* ElementCall */ = { isa = PBXGroup; children = ( + E6943C2B0925AB90ECAFE59F /* CXCallControllerProtocol.swift */, 5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */, CC437C491EA6996513B1CEAB /* ElementCallConfiguration.swift */, 33AE897D86784CCA5E4E9227 /* ElementCallService.swift */, @@ -6956,6 +6989,14 @@ path = View; sourceTree = ""; }; + DE07A244ACE83E40D894EDF3 /* NativeCallScreen */ = { + isa = PBXGroup; + children = ( + 55421139EC9E8C2D8218AC57 /* NativeCallScreenCoordinator.swift */, + ); + path = NativeCallScreen; + sourceTree = ""; + }; DE77D5FC2A361306E4440687 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -7042,6 +7083,7 @@ 87E2774157D9C4894BCFF3F8 /* MediaPickerScreen */, 23605DD08620BE6558242469 /* MediaUploadPreviewScreen */, 3348D14DBDB54E72FC67E2F3 /* MessageForwardingScreen */, + DE07A244ACE83E40D894EDF3 /* NativeCallScreen */, 8F074E22FD93E64211971845 /* Onboarding */, A448A3A8F764174C60CD0CA1 /* Other */, 3E535010B850B53DDD3CFF2A /* PinnedEventsTimelineScreen */, @@ -7685,6 +7727,7 @@ 1A6B622CCFDEFB92D9CF1CA5 /* LoremSwiftum */, 1081D3630AAD3ACEDDEC3A98 /* LRUCache */, 8C30177487BBB5D000E4DDDA /* Mantis */, + 99570A04BD7A395538321E94 /* ElementCall */, 4278261E147DB2DE5CFB7FC5 /* PostHog */, 9573B94B1C86C6DF751AF3FD /* SwiftState */, 997C7385E1A07E061D7E2100 /* GZIP */, @@ -7867,6 +7910,7 @@ 4C34425923978C97409A3EF2 /* XCRemoteSwiftPackageReference "DSWaveformImage" */, D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */, 75CC627F59499C9E93D8FDAD /* XCRemoteSwiftPackageReference "Dynamic" */, + 24D50023D92B93BBFF6E14FD /* XCRemoteSwiftPackageReference "element-call-ios" */, 8213398B60F9B660D9216ADF /* XCRemoteSwiftPackageReference "element-call-swift" */, 821C67C9A7F8CC3FD41B28B4 /* XCRemoteSwiftPackageReference "emojibase-bindings" */, 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */, @@ -8350,6 +8394,7 @@ 1146E9EDCF8344F7D6E0D553 /* MockCoder.swift in Sources */, DC68E866D6E664B0D2B06E74 /* MockImageCache.swift in Sources */, 3F791C4E0F7B373DB217EC90 /* NSItemProviderTests.swift in Sources */, + FBFFABBDCDEC3C0C03430E19 /* NativeCallStackTests.swift in Sources */, 4F51C7C8131D060108B80575 /* NavigationCoordinators.swift in Sources */, 981853650217B6C8ECDD998C /* NavigationRootCoordinatorTests.swift in Sources */, 69C7B956B74BEC3DB88224EA /* NavigationSplitCoordinatorTests.swift in Sources */, @@ -8676,6 +8721,8 @@ 783FFA8F0DB76A1CB23B0ECE /* CLLocationCoordinate2D.swift in Sources */, 6A5FDF9306CBD62C7EDDB552 /* CLLocationManagerMock.swift in Sources */, 1D2D713A5A269072D103AE37 /* CLLocationManagerProtocol.swift in Sources */, + 93DED70DA65F7B6889D60CD8 /* CXCallControllerMock.swift in Sources */, + 4EAC1586FF01F6AB8B8D59F9 /* CXCallControllerProtocol.swift in Sources */, 5470E62F65AE1803BBF3D528 /* CXProviderMock.swift in Sources */, 3D0DAED550E967AB49F1758C /* CXProviderProtocol.swift in Sources */, E81E8B7A8ADDB2A912A609F6 /* CallIntent.swift in Sources */, @@ -9051,6 +9098,11 @@ 551E03A935A6B73158A1AB4A /* NSEUserSessionMock.swift in Sources */, F9842667B68DC6FA1F9ECCBB /* NSItemProvider.swift in Sources */, EA01A06EEDFEF4AE7652E5F3 /* NSRegularExpresion.swift in Sources */, + 2C8926703BDDD3ECF2AC028D /* NativeCallRoomContext.swift in Sources */, + 2D77236B66808B661731BC07 /* NativeCallScreenCoordinator.swift in Sources */, + 14A948F9DC5994961F7179B6 /* NativeCallStack.swift in Sources */, + 868C8DDDAF80086B0C376FFF /* NativeCallStyle.swift in Sources */, + 4A9786829B79BD13EF1626C1 /* NativeCallSystem.swift in Sources */, FA2BBAE9FC5E2E9F960C0980 /* NavigationCoordinators.swift in Sources */, 71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */, B5E455C9689EA600EDB3E9E0 /* NavigationRootCoordinator.swift in Sources */, @@ -10313,6 +10365,7 @@ ENABLE_BITCODE = NO; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -10388,6 +10441,7 @@ ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -10800,6 +10854,14 @@ version = 6.29.0; }; }; + 24D50023D92B93BBFF6E14FD /* XCRemoteSwiftPackageReference "element-call-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/element-hq/element-call-ios"; + requirement = { + kind = exactVersion; + version = "0.1.0-rc.6"; + }; + }; 395DE6AE429B7ACC7C7FE31D /* XCRemoteSwiftPackageReference "KZFileWatchers" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/krzysztofzablocki/KZFileWatchers"; @@ -11227,6 +11289,11 @@ package = 6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */; productName = SwiftState; }; + 99570A04BD7A395538321E94 /* ElementCall */ = { + isa = XCSwiftPackageProductDependency; + package = 24D50023D92B93BBFF6E14FD /* XCRemoteSwiftPackageReference "element-call-ios" */; + productName = ElementCall; + }; 997C7385E1A07E061D7E2100 /* GZIP */ = { isa = XCSwiftPackageProductDependency; package = 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */; diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7ed795a52..e3d4410fb 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b74cb643b63fefdb973bc88e670a30441195770c21a88cceea0f42789cdb8f51", + "originHash" : "d67cd8d83bc81d99691ee1fabd9369d308f1dc701490e2b260a4527744e8d9d7", "pins" : [ { "identity" : "compound-design-tokens", @@ -55,6 +55,15 @@ "version" : "1.2.0" } }, + { + "identity" : "element-call-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/element-hq/element-call-ios", + "state" : { + "revision" : "3f6491aa34077da23862651c5eb1e119cd947d66", + "version" : "0.1.0-rc.6" + } + }, { "identity" : "element-call-swift", "kind" : "remoteSourceControl", @@ -171,6 +180,15 @@ "version" : "26.9.17" } }, + { + "identity" : "matrix-rust-rtc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/BillCarsonFr/matrix-rust-rtc", + "state" : { + "revision" : "9736f0f6dc47808aadfb35c3307791f0f5803848", + "version" : "0.2.0-rc.1" + } + }, { "identity" : "ogg-swift", "kind" : "remoteSourceControl", diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index bfd0e38b5..7132be43b 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -113,7 +113,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg userIndicatorController = UserIndicatorController() - elementCallService = ElementCallService() + elementCallService = ElementCallService(appSettings: appSettings) navigationRootCoordinator = NavigationRootCoordinator() @@ -826,6 +826,9 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg Task { await pauseClientServices(isBackgroundTask: false) } userSessionFlowCoordinator?.stop() + // Leaves any ongoing call and releases the native call stack, which a soft logout would + // otherwise leave running against a session that can no longer reach the server. + elementCallService.setUserSession(nil) guard !isSoft else { stateMachine.processEvent(.showSoftLogout) @@ -895,7 +898,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg fatalError("User session not setup") } - elementCallService.setClientProxy(userSession.clientProxy) + elementCallService.setUserSession(userSession) } private func configureNotificationManager() { diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 8076849bc..5b93ae296 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -472,6 +472,10 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: AppBuildType.current != .release) var developerOptionsEnabled: Bool + /// Runs calls through the native matrix-rust-rtc stack instead of the Element Call web view. + @UserPreference(defaultValue: false) + var nativeCallEnabled: Bool + init(store: UserDefaultsProtocol) { self.store = store } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 589f6fdc8..0d9d54da0 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -9,6 +9,7 @@ import AVKit import Combine import Compound +import ElementCall import SwiftState import SwiftUI @@ -227,7 +228,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } - // swiftlint:disable:next function_body_length private func setupObservers() { chatsTabFlowCoordinator.actionsPublisher .sink { [weak self] action in @@ -316,17 +316,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } .store(in: &cancellables) - flowParameters.elementCallService.actions - .receive(on: DispatchQueue.main) - .sink { [weak self] action in - switch action { - case .endCall: - self?.dismissCallScreenIfNeeded() - default: - break - } - } - .store(in: &cancellables) + setupCallObservers() searchScreenCoordinator?.actionsPublisher .sink { [weak self] action in @@ -458,6 +448,10 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private func presentCallScreen(roomID: String, isVoiceCall: Bool) async { guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else { + // An answered call is left up for the native stack, so it has to be ended here rather + // than leaving the system with a call this room can no longer serve. + MXLog.error("Cannot present the call screen, \(roomID) isn't a joined room") + flowParameters.elementCallService.tearDownCallSession(roomID: roomID) return } @@ -477,6 +471,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private var callScreenPictureInPictureController: AVPictureInPictureController? private func presentCallScreen(configuration: ElementCallConfiguration) { + // The service runs native calls and drives their screen through its own actions. + if flowParameters.elementCallService.handleNativeCallRequest(roomProxy: configuration.roomProxy, + isVoiceCall: configuration.voiceOnly) { + return + } + guard flowParameters.ongoingCallRoomIDPublisher.value != configuration.callRoomID else { MXLog.info("Returning to existing call.") callScreenPictureInPictureController?.stopPictureInPicture() @@ -514,6 +514,11 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } private func hideCallScreenOverlay() { + if navigationTabCoordinator.overlayCoordinator is NativeCallScreenCoordinator { + flowParameters.elementCallService.minimizeNativeCall() + return + } + guard let callScreenPictureInPictureController else { MXLog.warning("Picture in picture isn't available, dismissing the call screen.") dismissCallScreenIfNeeded() @@ -525,8 +530,66 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { navigationTabCoordinator.setOverlayPresentationMode(.minimized) } + // MARK: - Native calls + + private func setupCallObservers() { + flowParameters.elementCallService.actions + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + guard let self else { return } + switch action { + case .endCall: + // A native call comes down on `.nativeCall(.dismiss)` instead, once it has + // actually left the room. + if navigationTabCoordinator.overlayCoordinator is CallScreenCoordinator { + dismissCallScreenIfNeeded() + } + case .nativeCall(let presentation): + handleNativeCall(presentation) + default: + break + } + } + .store(in: &cancellables) + } + + private func handleNativeCall(_ presentation: NativeCallPresentation) { + switch presentation { + case .present: + presentNativeCallScreen() + case .restore: + restoreNativeCallScreen() + case .minimize: + navigationTabCoordinator.setOverlayPresentationMode(.minimized) + case .dismiss: + dismissCallScreenIfNeeded() + } + } + + private func presentNativeCallScreen() { + guard let controller = flowParameters.elementCallService.nativeCallController else { + MXLog.error("Cannot present a native call without a call stack") + return + } + + guard !(navigationTabCoordinator.overlayCoordinator is NativeCallScreenCoordinator) else { + restoreNativeCallScreen() + return + } + + let coordinator = NativeCallScreenCoordinator(parameters: .init(controller: controller)) + navigationTabCoordinator.setOverlayCoordinator(coordinator, animated: true) + flowParameters.analytics.track(screen: .RoomCall) + } + + private func restoreNativeCallScreen() { + flowParameters.elementCallService.restoreNativeCall() + navigationTabCoordinator.setOverlayPresentationMode(.fullScreen) + } + private func dismissCallScreenIfNeeded() { - guard navigationTabCoordinator.overlayCoordinator is CallScreenCoordinator else { + guard navigationTabCoordinator.overlayCoordinator is CallScreenCoordinator + || navigationTabCoordinator.overlayCoordinator is NativeCallScreenCoordinator else { return } diff --git a/ElementX/Sources/Mocks/CXCallControllerMock.swift b/ElementX/Sources/Mocks/CXCallControllerMock.swift new file mode 100644 index 000000000..fc1324b97 --- /dev/null +++ b/ElementX/Sources/Mocks/CXCallControllerMock.swift @@ -0,0 +1,17 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +extension CXCallControllerMock { + struct Configuration { } + + convenience init(_ configuration: Configuration) { + self.init() + requestCompletionClosure = { _, completion in + completion(nil) + } + } +} diff --git a/ElementX/Sources/Mocks/ElementCallServiceMock.swift b/ElementX/Sources/Mocks/ElementCallServiceMock.swift index 8c0d5d16d..982f24575 100644 --- a/ElementX/Sources/Mocks/ElementCallServiceMock.swift +++ b/ElementX/Sources/Mocks/ElementCallServiceMock.swift @@ -19,5 +19,6 @@ extension ElementCallServiceMock { actions = PassthroughSubject().eraseToAnyPublisher() ongoingCallRoomIDPublisher = .init(.init(configuration.ongoingCallRoomID)) + handleNativeCallRequestRoomProxyIsVoiceCallReturnValue = false } } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 6fa2f8597..b0c4aa654 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -10,6 +10,7 @@ import AnalyticsEvents import AVFoundation import CallKit +import ElementCall import Foundation import LocalAuthentication import Photos @@ -1853,6 +1854,40 @@ nonisolated class CLLocationManagerMock: CLLocationManagerProtocol, @unchecked S stopUpdatingLocationClosure?() } } +nonisolated class CXCallControllerMock: CXCallControllerProtocol, @unchecked Sendable { + + //MARK: - request + + private let requestCompletionCallsCountLock = NSLock() + private nonisolated(unsafe) var requestCompletionUnderlyingCallsCount = 0 + var requestCompletionCallsCount: Int { + get { requestCompletionCallsCountLock.withLock { requestCompletionUnderlyingCallsCount } } + set { requestCompletionCallsCountLock.withLock { requestCompletionUnderlyingCallsCount = newValue } } + } + var requestCompletionCalled: Bool { + return requestCompletionCallsCount > 0 + } + private let requestCompletionReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var requestCompletionUnderlyingReceivedArguments: (transaction: CXTransaction, completion: (Error?) -> Void)? + var requestCompletionReceivedArguments: (transaction: CXTransaction, completion: (Error?) -> Void)? { + get { requestCompletionReceivedArgumentsLock.withLock { requestCompletionUnderlyingReceivedArguments } } + set { requestCompletionReceivedArgumentsLock.withLock { requestCompletionUnderlyingReceivedArguments = newValue } } + } + private let requestCompletionReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var requestCompletionUnderlyingReceivedInvocations: [(transaction: CXTransaction, completion: (Error?) -> Void)] = [] + var requestCompletionReceivedInvocations: [(transaction: CXTransaction, completion: (Error?) -> Void)] { + get { requestCompletionReceivedInvocationsLock.withLock { requestCompletionUnderlyingReceivedInvocations } } + set { requestCompletionReceivedInvocationsLock.withLock { requestCompletionUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var requestCompletionClosure: ((CXTransaction, @Sendable @escaping (Error?) -> Void) -> Void)? + + func request(_ transaction: CXTransaction, completion: @Sendable @escaping (Error?) -> Void) { + requestCompletionCallsCountLock.withLock { requestCompletionUnderlyingCallsCount += 1 } + requestCompletionReceivedArguments = (transaction: transaction, completion: completion) + requestCompletionReceivedInvocationsLock.withLock { requestCompletionUnderlyingReceivedInvocations.append((transaction: transaction, completion: completion)) } + requestCompletionClosure?(transaction, completion) + } +} nonisolated class CXProviderMock: CXProviderProtocol, @unchecked Sendable { //MARK: - setDelegate @@ -1917,6 +1952,99 @@ nonisolated class CXProviderMock: CXProviderProtocol, @unchecked Sendable { reportNewIncomingCallWithUpdateCompletionReceivedInvocationsLock.withLock { reportNewIncomingCallWithUpdateCompletionUnderlyingReceivedInvocations.append((uuid: uuid, update: update, completion: completion)) } reportNewIncomingCallWithUpdateCompletionClosure?(uuid, update, completion) } + //MARK: - reportOutgoingCall + + private let reportOutgoingCallWithStartedConnectingAtCallsCountLock = NSLock() + private nonisolated(unsafe) var reportOutgoingCallWithStartedConnectingAtUnderlyingCallsCount = 0 + var reportOutgoingCallWithStartedConnectingAtCallsCount: Int { + get { reportOutgoingCallWithStartedConnectingAtCallsCountLock.withLock { reportOutgoingCallWithStartedConnectingAtUnderlyingCallsCount } } + set { reportOutgoingCallWithStartedConnectingAtCallsCountLock.withLock { reportOutgoingCallWithStartedConnectingAtUnderlyingCallsCount = newValue } } + } + var reportOutgoingCallWithStartedConnectingAtCalled: Bool { + return reportOutgoingCallWithStartedConnectingAtCallsCount > 0 + } + private let reportOutgoingCallWithStartedConnectingAtReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var reportOutgoingCallWithStartedConnectingAtUnderlyingReceivedArguments: (uuid: UUID, startedConnectingAt: Date?)? + var reportOutgoingCallWithStartedConnectingAtReceivedArguments: (uuid: UUID, startedConnectingAt: Date?)? { + get { reportOutgoingCallWithStartedConnectingAtReceivedArgumentsLock.withLock { reportOutgoingCallWithStartedConnectingAtUnderlyingReceivedArguments } } + set { reportOutgoingCallWithStartedConnectingAtReceivedArgumentsLock.withLock { reportOutgoingCallWithStartedConnectingAtUnderlyingReceivedArguments = newValue } } + } + private let reportOutgoingCallWithStartedConnectingAtReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var reportOutgoingCallWithStartedConnectingAtUnderlyingReceivedInvocations: [(uuid: UUID, startedConnectingAt: Date?)] = [] + var reportOutgoingCallWithStartedConnectingAtReceivedInvocations: [(uuid: UUID, startedConnectingAt: Date?)] { + get { reportOutgoingCallWithStartedConnectingAtReceivedInvocationsLock.withLock { reportOutgoingCallWithStartedConnectingAtUnderlyingReceivedInvocations } } + set { reportOutgoingCallWithStartedConnectingAtReceivedInvocationsLock.withLock { reportOutgoingCallWithStartedConnectingAtUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var reportOutgoingCallWithStartedConnectingAtClosure: ((UUID, Date?) -> Void)? + + func reportOutgoingCall(with uuid: UUID, startedConnectingAt: Date?) { + reportOutgoingCallWithStartedConnectingAtCallsCountLock.withLock { reportOutgoingCallWithStartedConnectingAtUnderlyingCallsCount += 1 } + reportOutgoingCallWithStartedConnectingAtReceivedArguments = (uuid: uuid, startedConnectingAt: startedConnectingAt) + reportOutgoingCallWithStartedConnectingAtReceivedInvocationsLock.withLock { reportOutgoingCallWithStartedConnectingAtUnderlyingReceivedInvocations.append((uuid: uuid, startedConnectingAt: startedConnectingAt)) } + reportOutgoingCallWithStartedConnectingAtClosure?(uuid, startedConnectingAt) + } + //MARK: - reportOutgoingCall + + private let reportOutgoingCallWithConnectedAtCallsCountLock = NSLock() + private nonisolated(unsafe) var reportOutgoingCallWithConnectedAtUnderlyingCallsCount = 0 + var reportOutgoingCallWithConnectedAtCallsCount: Int { + get { reportOutgoingCallWithConnectedAtCallsCountLock.withLock { reportOutgoingCallWithConnectedAtUnderlyingCallsCount } } + set { reportOutgoingCallWithConnectedAtCallsCountLock.withLock { reportOutgoingCallWithConnectedAtUnderlyingCallsCount = newValue } } + } + var reportOutgoingCallWithConnectedAtCalled: Bool { + return reportOutgoingCallWithConnectedAtCallsCount > 0 + } + private let reportOutgoingCallWithConnectedAtReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var reportOutgoingCallWithConnectedAtUnderlyingReceivedArguments: (uuid: UUID, connectedAt: Date?)? + var reportOutgoingCallWithConnectedAtReceivedArguments: (uuid: UUID, connectedAt: Date?)? { + get { reportOutgoingCallWithConnectedAtReceivedArgumentsLock.withLock { reportOutgoingCallWithConnectedAtUnderlyingReceivedArguments } } + set { reportOutgoingCallWithConnectedAtReceivedArgumentsLock.withLock { reportOutgoingCallWithConnectedAtUnderlyingReceivedArguments = newValue } } + } + private let reportOutgoingCallWithConnectedAtReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var reportOutgoingCallWithConnectedAtUnderlyingReceivedInvocations: [(uuid: UUID, connectedAt: Date?)] = [] + var reportOutgoingCallWithConnectedAtReceivedInvocations: [(uuid: UUID, connectedAt: Date?)] { + get { reportOutgoingCallWithConnectedAtReceivedInvocationsLock.withLock { reportOutgoingCallWithConnectedAtUnderlyingReceivedInvocations } } + set { reportOutgoingCallWithConnectedAtReceivedInvocationsLock.withLock { reportOutgoingCallWithConnectedAtUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var reportOutgoingCallWithConnectedAtClosure: ((UUID, Date?) -> Void)? + + func reportOutgoingCall(with uuid: UUID, connectedAt: Date?) { + reportOutgoingCallWithConnectedAtCallsCountLock.withLock { reportOutgoingCallWithConnectedAtUnderlyingCallsCount += 1 } + reportOutgoingCallWithConnectedAtReceivedArguments = (uuid: uuid, connectedAt: connectedAt) + reportOutgoingCallWithConnectedAtReceivedInvocationsLock.withLock { reportOutgoingCallWithConnectedAtUnderlyingReceivedInvocations.append((uuid: uuid, connectedAt: connectedAt)) } + reportOutgoingCallWithConnectedAtClosure?(uuid, connectedAt) + } + //MARK: - reportCall + + private let reportCallWithUpdatedCallsCountLock = NSLock() + private nonisolated(unsafe) var reportCallWithUpdatedUnderlyingCallsCount = 0 + var reportCallWithUpdatedCallsCount: Int { + get { reportCallWithUpdatedCallsCountLock.withLock { reportCallWithUpdatedUnderlyingCallsCount } } + set { reportCallWithUpdatedCallsCountLock.withLock { reportCallWithUpdatedUnderlyingCallsCount = newValue } } + } + var reportCallWithUpdatedCalled: Bool { + return reportCallWithUpdatedCallsCount > 0 + } + private let reportCallWithUpdatedReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var reportCallWithUpdatedUnderlyingReceivedArguments: (uuid: UUID, update: CXCallUpdate)? + var reportCallWithUpdatedReceivedArguments: (uuid: UUID, update: CXCallUpdate)? { + get { reportCallWithUpdatedReceivedArgumentsLock.withLock { reportCallWithUpdatedUnderlyingReceivedArguments } } + set { reportCallWithUpdatedReceivedArgumentsLock.withLock { reportCallWithUpdatedUnderlyingReceivedArguments = newValue } } + } + private let reportCallWithUpdatedReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var reportCallWithUpdatedUnderlyingReceivedInvocations: [(uuid: UUID, update: CXCallUpdate)] = [] + var reportCallWithUpdatedReceivedInvocations: [(uuid: UUID, update: CXCallUpdate)] { + get { reportCallWithUpdatedReceivedInvocationsLock.withLock { reportCallWithUpdatedUnderlyingReceivedInvocations } } + set { reportCallWithUpdatedReceivedInvocationsLock.withLock { reportCallWithUpdatedUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var reportCallWithUpdatedClosure: ((UUID, CXCallUpdate) -> Void)? + + func reportCall(with uuid: UUID, updated update: CXCallUpdate) { + reportCallWithUpdatedCallsCountLock.withLock { reportCallWithUpdatedUnderlyingCallsCount += 1 } + reportCallWithUpdatedReceivedArguments = (uuid: uuid, update: update) + reportCallWithUpdatedReceivedInvocationsLock.withLock { reportCallWithUpdatedUnderlyingReceivedInvocations.append((uuid: uuid, update: update)) } + reportCallWithUpdatedClosure?(uuid, update) + } //MARK: - reportCall private let reportCallWithEndedAtReasonCallsCountLock = NSLock() @@ -2453,6 +2581,34 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { } nonisolated(unsafe) var underlyingLiveLocationOwnInfoUpdatesPublisher: AnyPublisher! + //MARK: - makeNativeCallTransport + + private let makeNativeCallTransportCallsCountLock = NSLock() + private nonisolated(unsafe) var makeNativeCallTransportUnderlyingCallsCount = 0 + var makeNativeCallTransportCallsCount: Int { + get { makeNativeCallTransportCallsCountLock.withLock { makeNativeCallTransportUnderlyingCallsCount } } + set { makeNativeCallTransportCallsCountLock.withLock { makeNativeCallTransportUnderlyingCallsCount = newValue } } + } + var makeNativeCallTransportCalled: Bool { + return makeNativeCallTransportCallsCount > 0 + } + + private let makeNativeCallTransportReturnValueLock = NSLock() + private nonisolated(unsafe) var makeNativeCallTransportUnderlyingReturnValue: ElementCallMatrixTransportProtocol? + var makeNativeCallTransportReturnValue: ElementCallMatrixTransportProtocol? { + get { makeNativeCallTransportReturnValueLock.withLock { makeNativeCallTransportUnderlyingReturnValue } } + set { makeNativeCallTransportReturnValueLock.withLock { makeNativeCallTransportUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var makeNativeCallTransportClosure: (() -> ElementCallMatrixTransportProtocol?)? + + func makeNativeCallTransport() -> ElementCallMatrixTransportProtocol? { + makeNativeCallTransportCallsCountLock.withLock { makeNativeCallTransportUnderlyingCallsCount += 1 } + if let makeNativeCallTransportClosure = makeNativeCallTransportClosure { + return makeNativeCallTransportClosure() + } else { + return makeNativeCallTransportReturnValue + } + } //MARK: - isOnlyDeviceLeft private let isOnlyDeviceLeftCallsCountLock = NSLock() @@ -4842,85 +4998,208 @@ nonisolated class ElementCallServiceMock: ElementCallServiceProtocol, @unchecked set(value) { underlyingOngoingCallRoomIDPublisher = value } } nonisolated(unsafe) var underlyingOngoingCallRoomIDPublisher: CurrentValuePublisher! + nonisolated(unsafe) var nativeCallController: ElementCallController? - //MARK: - setClientProxy + //MARK: - setUserSession - private let setClientProxyCallsCountLock = NSLock() - private nonisolated(unsafe) var setClientProxyUnderlyingCallsCount = 0 - var setClientProxyCallsCount: Int { - get { setClientProxyCallsCountLock.withLock { setClientProxyUnderlyingCallsCount } } - set { setClientProxyCallsCountLock.withLock { setClientProxyUnderlyingCallsCount = newValue } } + private let setUserSessionCallsCountLock = NSLock() + private nonisolated(unsafe) var setUserSessionUnderlyingCallsCount = 0 + var setUserSessionCallsCount: Int { + get { setUserSessionCallsCountLock.withLock { setUserSessionUnderlyingCallsCount } } + set { setUserSessionCallsCountLock.withLock { setUserSessionUnderlyingCallsCount = newValue } } } - var setClientProxyCalled: Bool { - return setClientProxyCallsCount > 0 + var setUserSessionCalled: Bool { + return setUserSessionCallsCount > 0 } - private let setClientProxyReceivedClientProxyLock = NSLock() - private nonisolated(unsafe) var setClientProxyUnderlyingReceivedClientProxy: ClientProxyProtocol? - var setClientProxyReceivedClientProxy: ClientProxyProtocol? { - get { setClientProxyReceivedClientProxyLock.withLock { setClientProxyUnderlyingReceivedClientProxy } } - set { setClientProxyReceivedClientProxyLock.withLock { setClientProxyUnderlyingReceivedClientProxy = newValue } } + private let setUserSessionReceivedUserSessionLock = NSLock() + private nonisolated(unsafe) var setUserSessionUnderlyingReceivedUserSession: UserSessionProtocol? + var setUserSessionReceivedUserSession: UserSessionProtocol? { + get { setUserSessionReceivedUserSessionLock.withLock { setUserSessionUnderlyingReceivedUserSession } } + set { setUserSessionReceivedUserSessionLock.withLock { setUserSessionUnderlyingReceivedUserSession = newValue } } } - private let setClientProxyReceivedInvocationsLock = NSLock() - private nonisolated(unsafe) var setClientProxyUnderlyingReceivedInvocations: [ClientProxyProtocol] = [] - var setClientProxyReceivedInvocations: [ClientProxyProtocol] { - get { setClientProxyReceivedInvocationsLock.withLock { setClientProxyUnderlyingReceivedInvocations } } - set { setClientProxyReceivedInvocationsLock.withLock { setClientProxyUnderlyingReceivedInvocations = newValue } } + private let setUserSessionReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var setUserSessionUnderlyingReceivedInvocations: [UserSessionProtocol?] = [] + var setUserSessionReceivedInvocations: [UserSessionProtocol?] { + get { setUserSessionReceivedInvocationsLock.withLock { setUserSessionUnderlyingReceivedInvocations } } + set { setUserSessionReceivedInvocationsLock.withLock { setUserSessionUnderlyingReceivedInvocations = newValue } } } - nonisolated(unsafe) var setClientProxyClosure: ((ClientProxyProtocol) -> Void)? + nonisolated(unsafe) var setUserSessionClosure: ((UserSessionProtocol?) -> Void)? - func setClientProxy(_ clientProxy: ClientProxyProtocol) { - setClientProxyCallsCountLock.withLock { setClientProxyUnderlyingCallsCount += 1 } - setClientProxyReceivedClientProxy = clientProxy - setClientProxyReceivedInvocationsLock.withLock { setClientProxyUnderlyingReceivedInvocations.append(clientProxy) } - setClientProxyClosure?(clientProxy) + func setUserSession(_ userSession: UserSessionProtocol?) { + setUserSessionCallsCountLock.withLock { setUserSessionUnderlyingCallsCount += 1 } + setUserSessionReceivedUserSession = userSession + setUserSessionReceivedInvocationsLock.withLock { setUserSessionUnderlyingReceivedInvocations.append(userSession) } + setUserSessionClosure?(userSession) + } + //MARK: - handleNativeCallRequest + + private let handleNativeCallRequestRoomProxyIsVoiceCallCallsCountLock = NSLock() + private nonisolated(unsafe) var handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingCallsCount = 0 + var handleNativeCallRequestRoomProxyIsVoiceCallCallsCount: Int { + get { handleNativeCallRequestRoomProxyIsVoiceCallCallsCountLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingCallsCount } } + set { handleNativeCallRequestRoomProxyIsVoiceCallCallsCountLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingCallsCount = newValue } } + } + var handleNativeCallRequestRoomProxyIsVoiceCallCalled: Bool { + return handleNativeCallRequestRoomProxyIsVoiceCallCallsCount > 0 + } + private let handleNativeCallRequestRoomProxyIsVoiceCallReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)? + var handleNativeCallRequestRoomProxyIsVoiceCallReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)? { + get { handleNativeCallRequestRoomProxyIsVoiceCallReceivedArgumentsLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReceivedArguments } } + set { handleNativeCallRequestRoomProxyIsVoiceCallReceivedArgumentsLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReceivedArguments = newValue } } + } + private let handleNativeCallRequestRoomProxyIsVoiceCallReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)] = [] + var handleNativeCallRequestRoomProxyIsVoiceCallReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)] { + get { handleNativeCallRequestRoomProxyIsVoiceCallReceivedInvocationsLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReceivedInvocations } } + set { handleNativeCallRequestRoomProxyIsVoiceCallReceivedInvocationsLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReceivedInvocations = newValue } } + } + + private let handleNativeCallRequestRoomProxyIsVoiceCallReturnValueLock = NSLock() + private nonisolated(unsafe) var handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReturnValue: Bool! + var handleNativeCallRequestRoomProxyIsVoiceCallReturnValue: Bool! { + get { handleNativeCallRequestRoomProxyIsVoiceCallReturnValueLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReturnValue } } + set { handleNativeCallRequestRoomProxyIsVoiceCallReturnValueLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var handleNativeCallRequestRoomProxyIsVoiceCallClosure: ((JoinedRoomProxyProtocol, Bool) -> Bool)? + + @discardableResult + func handleNativeCallRequest(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool) -> Bool { + handleNativeCallRequestRoomProxyIsVoiceCallCallsCountLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingCallsCount += 1 } + handleNativeCallRequestRoomProxyIsVoiceCallReceivedArguments = (roomProxy: roomProxy, isVoiceCall: isVoiceCall) + handleNativeCallRequestRoomProxyIsVoiceCallReceivedInvocationsLock.withLock { handleNativeCallRequestRoomProxyIsVoiceCallUnderlyingReceivedInvocations.append((roomProxy: roomProxy, isVoiceCall: isVoiceCall)) } + if let handleNativeCallRequestRoomProxyIsVoiceCallClosure = handleNativeCallRequestRoomProxyIsVoiceCallClosure { + return handleNativeCallRequestRoomProxyIsVoiceCallClosure(roomProxy, isVoiceCall) + } else { + return handleNativeCallRequestRoomProxyIsVoiceCallReturnValue + } + } + //MARK: - minimizeNativeCall + + private let minimizeNativeCallCallsCountLock = NSLock() + private nonisolated(unsafe) var minimizeNativeCallUnderlyingCallsCount = 0 + var minimizeNativeCallCallsCount: Int { + get { minimizeNativeCallCallsCountLock.withLock { minimizeNativeCallUnderlyingCallsCount } } + set { minimizeNativeCallCallsCountLock.withLock { minimizeNativeCallUnderlyingCallsCount = newValue } } + } + var minimizeNativeCallCalled: Bool { + return minimizeNativeCallCallsCount > 0 + } + nonisolated(unsafe) var minimizeNativeCallClosure: (() -> Void)? + + func minimizeNativeCall() { + minimizeNativeCallCallsCountLock.withLock { minimizeNativeCallUnderlyingCallsCount += 1 } + minimizeNativeCallClosure?() + } + //MARK: - restoreNativeCall + + private let restoreNativeCallCallsCountLock = NSLock() + private nonisolated(unsafe) var restoreNativeCallUnderlyingCallsCount = 0 + var restoreNativeCallCallsCount: Int { + get { restoreNativeCallCallsCountLock.withLock { restoreNativeCallUnderlyingCallsCount } } + set { restoreNativeCallCallsCountLock.withLock { restoreNativeCallUnderlyingCallsCount = newValue } } + } + var restoreNativeCallCalled: Bool { + return restoreNativeCallCallsCount > 0 + } + nonisolated(unsafe) var restoreNativeCallClosure: (() -> Void)? + + func restoreNativeCall() { + restoreNativeCallCallsCountLock.withLock { restoreNativeCallUnderlyingCallsCount += 1 } + restoreNativeCallClosure?() } //MARK: - setupCallSession - private let setupCallSessionRoomIDRoomDisplayNameCallsCountLock = NSLock() - private nonisolated(unsafe) var setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount = 0 - var setupCallSessionRoomIDRoomDisplayNameCallsCount: Int { - get { setupCallSessionRoomIDRoomDisplayNameCallsCountLock.withLock { setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount } } - set { setupCallSessionRoomIDRoomDisplayNameCallsCountLock.withLock { setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount = newValue } } + private let setupCallSessionRoomIDRoomDisplayNameIsVideoCallsCountLock = NSLock() + private nonisolated(unsafe) var setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingCallsCount = 0 + var setupCallSessionRoomIDRoomDisplayNameIsVideoCallsCount: Int { + get { setupCallSessionRoomIDRoomDisplayNameIsVideoCallsCountLock.withLock { setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingCallsCount } } + set { setupCallSessionRoomIDRoomDisplayNameIsVideoCallsCountLock.withLock { setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingCallsCount = newValue } } } - var setupCallSessionRoomIDRoomDisplayNameCalled: Bool { - return setupCallSessionRoomIDRoomDisplayNameCallsCount > 0 + var setupCallSessionRoomIDRoomDisplayNameIsVideoCalled: Bool { + return setupCallSessionRoomIDRoomDisplayNameIsVideoCallsCount > 0 } - private let setupCallSessionRoomIDRoomDisplayNameReceivedArgumentsLock = NSLock() - private nonisolated(unsafe) var setupCallSessionRoomIDRoomDisplayNameUnderlyingReceivedArguments: (roomID: String, roomDisplayName: String)? - var setupCallSessionRoomIDRoomDisplayNameReceivedArguments: (roomID: String, roomDisplayName: String)? { - get { setupCallSessionRoomIDRoomDisplayNameReceivedArgumentsLock.withLock { setupCallSessionRoomIDRoomDisplayNameUnderlyingReceivedArguments } } - set { setupCallSessionRoomIDRoomDisplayNameReceivedArgumentsLock.withLock { setupCallSessionRoomIDRoomDisplayNameUnderlyingReceivedArguments = newValue } } + private let setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingReceivedArguments: (roomID: String, roomDisplayName: String, isVideo: Bool)? + var setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedArguments: (roomID: String, roomDisplayName: String, isVideo: Bool)? { + get { setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedArgumentsLock.withLock { setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingReceivedArguments } } + set { setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedArgumentsLock.withLock { setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingReceivedArguments = newValue } } } - private let setupCallSessionRoomIDRoomDisplayNameReceivedInvocationsLock = NSLock() - private nonisolated(unsafe) var setupCallSessionRoomIDRoomDisplayNameUnderlyingReceivedInvocations: [(roomID: String, roomDisplayName: String)] = [] - var setupCallSessionRoomIDRoomDisplayNameReceivedInvocations: [(roomID: String, roomDisplayName: String)] { - get { setupCallSessionRoomIDRoomDisplayNameReceivedInvocationsLock.withLock { setupCallSessionRoomIDRoomDisplayNameUnderlyingReceivedInvocations } } - set { setupCallSessionRoomIDRoomDisplayNameReceivedInvocationsLock.withLock { setupCallSessionRoomIDRoomDisplayNameUnderlyingReceivedInvocations = newValue } } + private let setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingReceivedInvocations: [(roomID: String, roomDisplayName: String, isVideo: Bool)] = [] + var setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedInvocations: [(roomID: String, roomDisplayName: String, isVideo: Bool)] { + get { setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedInvocationsLock.withLock { setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingReceivedInvocations } } + set { setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedInvocationsLock.withLock { setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingReceivedInvocations = newValue } } } - nonisolated(unsafe) var setupCallSessionRoomIDRoomDisplayNameClosure: ((String, String) async -> Void)? + nonisolated(unsafe) var setupCallSessionRoomIDRoomDisplayNameIsVideoClosure: ((String, String, Bool) async -> Void)? - @concurrent func setupCallSession(roomID: String, roomDisplayName: String) async { - setupCallSessionRoomIDRoomDisplayNameCallsCountLock.withLock { setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount += 1 } - setupCallSessionRoomIDRoomDisplayNameReceivedArguments = (roomID: roomID, roomDisplayName: roomDisplayName) - setupCallSessionRoomIDRoomDisplayNameReceivedInvocationsLock.withLock { setupCallSessionRoomIDRoomDisplayNameUnderlyingReceivedInvocations.append((roomID: roomID, roomDisplayName: roomDisplayName)) } - await setupCallSessionRoomIDRoomDisplayNameClosure?(roomID, roomDisplayName) + @concurrent func setupCallSession(roomID: String, roomDisplayName: String, isVideo: Bool) async { + setupCallSessionRoomIDRoomDisplayNameIsVideoCallsCountLock.withLock { setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingCallsCount += 1 } + setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedArguments = (roomID: roomID, roomDisplayName: roomDisplayName, isVideo: isVideo) + setupCallSessionRoomIDRoomDisplayNameIsVideoReceivedInvocationsLock.withLock { setupCallSessionRoomIDRoomDisplayNameIsVideoUnderlyingReceivedInvocations.append((roomID: roomID, roomDisplayName: roomDisplayName, isVideo: isVideo)) } + await setupCallSessionRoomIDRoomDisplayNameIsVideoClosure?(roomID, roomDisplayName, isVideo) + } + //MARK: - reportCallSessionConnected + + private let reportCallSessionConnectedRoomIDCallsCountLock = NSLock() + private nonisolated(unsafe) var reportCallSessionConnectedRoomIDUnderlyingCallsCount = 0 + var reportCallSessionConnectedRoomIDCallsCount: Int { + get { reportCallSessionConnectedRoomIDCallsCountLock.withLock { reportCallSessionConnectedRoomIDUnderlyingCallsCount } } + set { reportCallSessionConnectedRoomIDCallsCountLock.withLock { reportCallSessionConnectedRoomIDUnderlyingCallsCount = newValue } } + } + var reportCallSessionConnectedRoomIDCalled: Bool { + return reportCallSessionConnectedRoomIDCallsCount > 0 + } + private let reportCallSessionConnectedRoomIDReceivedRoomIDLock = NSLock() + private nonisolated(unsafe) var reportCallSessionConnectedRoomIDUnderlyingReceivedRoomID: String? + var reportCallSessionConnectedRoomIDReceivedRoomID: String? { + get { reportCallSessionConnectedRoomIDReceivedRoomIDLock.withLock { reportCallSessionConnectedRoomIDUnderlyingReceivedRoomID } } + set { reportCallSessionConnectedRoomIDReceivedRoomIDLock.withLock { reportCallSessionConnectedRoomIDUnderlyingReceivedRoomID = newValue } } + } + private let reportCallSessionConnectedRoomIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var reportCallSessionConnectedRoomIDUnderlyingReceivedInvocations: [String] = [] + var reportCallSessionConnectedRoomIDReceivedInvocations: [String] { + get { reportCallSessionConnectedRoomIDReceivedInvocationsLock.withLock { reportCallSessionConnectedRoomIDUnderlyingReceivedInvocations } } + set { reportCallSessionConnectedRoomIDReceivedInvocationsLock.withLock { reportCallSessionConnectedRoomIDUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var reportCallSessionConnectedRoomIDClosure: ((String) -> Void)? + + func reportCallSessionConnected(roomID: String) { + reportCallSessionConnectedRoomIDCallsCountLock.withLock { reportCallSessionConnectedRoomIDUnderlyingCallsCount += 1 } + reportCallSessionConnectedRoomIDReceivedRoomID = roomID + reportCallSessionConnectedRoomIDReceivedInvocationsLock.withLock { reportCallSessionConnectedRoomIDUnderlyingReceivedInvocations.append(roomID) } + reportCallSessionConnectedRoomIDClosure?(roomID) } //MARK: - tearDownCallSession - private let tearDownCallSessionCallsCountLock = NSLock() - private nonisolated(unsafe) var tearDownCallSessionUnderlyingCallsCount = 0 - var tearDownCallSessionCallsCount: Int { - get { tearDownCallSessionCallsCountLock.withLock { tearDownCallSessionUnderlyingCallsCount } } - set { tearDownCallSessionCallsCountLock.withLock { tearDownCallSessionUnderlyingCallsCount = newValue } } + private let tearDownCallSessionRoomIDCallsCountLock = NSLock() + private nonisolated(unsafe) var tearDownCallSessionRoomIDUnderlyingCallsCount = 0 + var tearDownCallSessionRoomIDCallsCount: Int { + get { tearDownCallSessionRoomIDCallsCountLock.withLock { tearDownCallSessionRoomIDUnderlyingCallsCount } } + set { tearDownCallSessionRoomIDCallsCountLock.withLock { tearDownCallSessionRoomIDUnderlyingCallsCount = newValue } } } - var tearDownCallSessionCalled: Bool { - return tearDownCallSessionCallsCount > 0 + var tearDownCallSessionRoomIDCalled: Bool { + return tearDownCallSessionRoomIDCallsCount > 0 } - nonisolated(unsafe) var tearDownCallSessionClosure: (() -> Void)? + private let tearDownCallSessionRoomIDReceivedRoomIDLock = NSLock() + private nonisolated(unsafe) var tearDownCallSessionRoomIDUnderlyingReceivedRoomID: String? + var tearDownCallSessionRoomIDReceivedRoomID: String? { + get { tearDownCallSessionRoomIDReceivedRoomIDLock.withLock { tearDownCallSessionRoomIDUnderlyingReceivedRoomID } } + set { tearDownCallSessionRoomIDReceivedRoomIDLock.withLock { tearDownCallSessionRoomIDUnderlyingReceivedRoomID = newValue } } + } + private let tearDownCallSessionRoomIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var tearDownCallSessionRoomIDUnderlyingReceivedInvocations: [String] = [] + var tearDownCallSessionRoomIDReceivedInvocations: [String] { + get { tearDownCallSessionRoomIDReceivedInvocationsLock.withLock { tearDownCallSessionRoomIDUnderlyingReceivedInvocations } } + set { tearDownCallSessionRoomIDReceivedInvocationsLock.withLock { tearDownCallSessionRoomIDUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var tearDownCallSessionRoomIDClosure: ((String) -> Void)? - func tearDownCallSession() { - tearDownCallSessionCallsCountLock.withLock { tearDownCallSessionUnderlyingCallsCount += 1 } - tearDownCallSessionClosure?() + func tearDownCallSession(roomID: String) { + tearDownCallSessionRoomIDCallsCountLock.withLock { tearDownCallSessionRoomIDUnderlyingCallsCount += 1 } + tearDownCallSessionRoomIDReceivedRoomID = roomID + tearDownCallSessionRoomIDReceivedInvocationsLock.withLock { tearDownCallSessionRoomIDUnderlyingReceivedInvocations.append(roomID) } + tearDownCallSessionRoomIDClosure?(roomID) } //MARK: - setAudioEnabled diff --git a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift index 769f7cef6..1b1302db3 100644 --- a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift +++ b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift @@ -26,10 +26,12 @@ nonisolated extension ClientSDKMock { // MARK: Session var userID: String? + static let defaultDeviceID = "ABCDEFGH" + var deviceID = defaultDeviceID var session = Session(accessToken: UUID().uuidString, refreshToken: nil, userId: "@alice:matrix.org", - deviceId: UUID().uuidString, + deviceId: defaultDeviceID, homeserverUrl: "https://matrix-client.matrix.org", oauthData: nil, slidingSyncVersion: .native) @@ -59,6 +61,7 @@ nonisolated extension ClientSDKMock { } userIdReturnValue = configuration.userID + deviceIdReturnValue = configuration.deviceID sessionReturnValue = configuration.session getUrlUrlClosure = { url in guard url.contains(".well-known/element/element.json") else { throw MockError.generic } diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift index 5fcc2e5a9..1a0299480 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift @@ -137,7 +137,7 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol await hangup() } - elementCallService.tearDownCallSession() + elementCallService.tearDownCallSession(roomID: configuration.callRoomID) UIDevice.current.isProximityMonitoringEnabled = false } @@ -196,7 +196,8 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol } await elementCallService.setupCallSession(roomID: configuration.roomProxy.id, - roomDisplayName: configuration.roomProxy.infoPublisher.value.displayName ?? configuration.roomProxy.id) + roomDisplayName: configuration.roomProxy.infoPublisher.value.displayNameOrID, + isVideo: !configuration.voiceOnly) } timeoutTask = Task { [weak self] in diff --git a/ElementX/Sources/Screens/NativeCallScreen/NativeCallScreenCoordinator.swift b/ElementX/Sources/Screens/NativeCallScreen/NativeCallScreenCoordinator.swift new file mode 100644 index 000000000..ea6e2b4c1 --- /dev/null +++ b/ElementX/Sources/Screens/NativeCallScreen/NativeCallScreenCoordinator.swift @@ -0,0 +1,26 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import ElementCall +import SwiftUI + +struct NativeCallScreenCoordinatorParameters { + let controller: ElementCallController +} + +final class NativeCallScreenCoordinator: CoordinatorProtocol { + private let viewModel: ElementCallScreenViewModel + + init(parameters: NativeCallScreenCoordinatorParameters) { + viewModel = ElementCallScreenViewModel(controller: parameters.controller) + } + + func toPresentable() -> AnyView { + AnyView(ElementCallScreen(viewModel: viewModel)) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 2a8e28de9..539eb609d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -67,7 +67,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol self.initialSelectedPinnedEventID = initialSelectedPinnedEventID pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID) - let viewState = RoomScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayName ?? roomProxy.id, + let viewState = RoomScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayNameOrID, roomAvatar: roomProxy.infoPublisher.value.avatar, hasOngoingCall: roomProxy.infoPublisher.value.hasRoomCall, isDM: roomProxy.infoPublisher.value.isDM, @@ -336,7 +336,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) { - state.roomTitle = roomInfo.displayName ?? roomProxy.id + state.roomTitle = roomInfo.displayNameOrID state.roomAvatar = roomInfo.avatar state.dmRecipientDetails.statusEmoji = roomInfo.statusEmoji state.hasOngoingCall = roomInfo.hasRoomCall diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 6cb3b7226..4293d73c4 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -74,6 +74,8 @@ protocol DeveloperOptionsProtocol: AnyObject { var linkNewDeviceEnabled: Bool { get set } var globalSearchEnabled: Bool { get set } + + var nativeCallEnabled: Bool { get set } } extension AppSettings: DeveloperOptionsProtocol { } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index b69ac19c3..569143ef9 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -120,7 +120,7 @@ struct DeveloperOptionsScreen: View { Text("This setting controls how end-to-end encryption (E2EE) keys are exchanged. Enabling it will prevent the inclusion of devices that have not been explicitly verified by their owners.") } - Section("Element Call remote URL override") { + Section { TextField("Leave empty to use EC locally", text: $elementCallURLOverrideString) .autocorrectionDisabled(true) .autocapitalization(.none) @@ -133,6 +133,13 @@ struct DeveloperOptionsScreen: View { context.elementCallBaseURLOverride = url } } + Toggle(isOn: $context.nativeCallEnabled) { + Text("Experimental native calls") + } + } header: { + Text("Calls") + } footer: { + Text("The URL override only applies to calls run in the Element Call web view, which native calls replace.") } Section("Notifications") { diff --git a/ElementX/Sources/Screens/Spaces/SpaceAddRoomsScreen/SpaceAddRoomsScreenModels.swift b/ElementX/Sources/Screens/Spaces/SpaceAddRoomsScreen/SpaceAddRoomsScreenModels.swift index b486bbb39..cf283ed2a 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceAddRoomsScreen/SpaceAddRoomsScreenModels.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceAddRoomsScreen/SpaceAddRoomsScreenModels.swift @@ -66,7 +66,7 @@ extension SpaceAddRoomsScreenRoom { init(roomProxy: JoinedRoomProxyProtocol) { self.init(id: roomProxy.id, - title: roomProxy.infoPublisher.value.displayName ?? roomProxy.id, + title: roomProxy.infoPublisher.value.displayNameOrID, description: roomProxy.infoPublisher.value.roomListDescription, avatar: roomProxy.infoPublisher.value.avatar) } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift index 87a83e9a4..6ef2a17fe 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift @@ -25,7 +25,7 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi self.roomProxy = roomProxy self.userSession = userSession - super.init(initialViewState: ThreadTimelineScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayName ?? roomProxy.id, + super.init(initialViewState: ThreadTimelineScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayNameOrID, roomAvatar: roomProxy.infoPublisher.value.avatar), mediaProvider: userSession.mediaProvider) roomProxy.infoPublisher @@ -101,7 +101,7 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi } private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) { - state.roomTitle = roomInfo.displayName ?? roomProxy.id + state.roomTitle = roomInfo.displayNameOrID state.roomAvatar = roomInfo.avatar state.dmRecipientDetails.statusEmoji = roomInfo.statusEmoji diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 212a868f4..59d663f28 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -8,6 +8,7 @@ @preconcurrency import Combine import CryptoKit +import ElementCall import Foundation import MatrixRustSDK import OrderedCollections @@ -374,6 +375,10 @@ class ClientProxy: ClientProxyProtocol { } } + func makeNativeCallTransport() -> ElementCallMatrixTransportProtocol? { + (client as? Client).flatMap { ElementCallSDKTransport(client: $0) } + } + var isLoginWithQRCodeSupported: Bool { get async { do { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index b70efff48..bc614825c 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -7,6 +7,7 @@ // import Combine +import ElementCall import Foundation import MatrixRustSDK @@ -171,6 +172,9 @@ protocol ClientProxyProtocol: AnyObject { var isReportRoomSupported: Bool { get async } var isLiveKitRTCSupported: Bool { get async } + /// Builds a Matrix transport for the call package, `nil` when not backed by a real SDK client. + func makeNativeCallTransport() -> ElementCallMatrixTransportProtocol? + var isLoginWithQRCodeSupported: Bool { get async } var maxMediaUploadSize: Result { get async } diff --git a/ElementX/Sources/Services/ElementCall/CXCallControllerProtocol.swift b/ElementX/Sources/Services/ElementCall/CXCallControllerProtocol.swift new file mode 100644 index 000000000..2f5d7af1e --- /dev/null +++ b/ElementX/Sources/Services/ElementCall/CXCallControllerProtocol.swift @@ -0,0 +1,15 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import CallKit + +// sourcery: AutoMockable +protocol CXCallControllerProtocol { + func request(_ transaction: CXTransaction, completion: @escaping @Sendable (Error?) -> Void) +} + +extension CXCallController: CXCallControllerProtocol { } diff --git a/ElementX/Sources/Services/ElementCall/CXProviderProtocol.swift b/ElementX/Sources/Services/ElementCall/CXProviderProtocol.swift index 39a0eca30..d3b8b6a55 100644 --- a/ElementX/Sources/Services/ElementCall/CXProviderProtocol.swift +++ b/ElementX/Sources/Services/ElementCall/CXProviderProtocol.swift @@ -11,6 +11,9 @@ import CallKit protocol CXProviderProtocol { func setDelegate(_ delegate: CXProviderDelegate?, queue: DispatchQueue?) func reportNewIncomingCall(with uuid: UUID, update: CXCallUpdate, completion: @escaping @Sendable (Error?) -> Void) + func reportOutgoingCall(with uuid: UUID, startedConnectingAt: Date?) + func reportOutgoingCall(with uuid: UUID, connectedAt: Date?) + func reportCall(with uuid: UUID, updated update: CXCallUpdate) func reportCall(with uuid: UUID, endedAt: Date?, reason: CXCallEndedReason) } diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index 9218b7838..37c6b93f8 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -9,6 +9,7 @@ import AVFoundation import CallKit import Combine +import ElementCall import Foundation import MatrixRustSDK import PushKit @@ -26,10 +27,12 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe let roomID: String let rtcNotificationID: String? let isVoiceCall: Bool + var isNative = false } + private let appSettings: AppSettings private let pushRegistry: PKPushRegistry - private let callController = CXCallController() + private let callController: CXCallControllerProtocol private let callProvider: CXProviderProtocol private let timeProvider: TimeProvider @@ -51,6 +54,10 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private var endUnansweredCallTask: Task? + /// A call answered for the native stack, until the stack takes it over. + private var answeredNativeCallID: CallID? + private var endPendingCallTask: Task? + private var ongoingCallID: CallID? { didSet { ongoingCallRoomIDSubject.send(ongoingCallID?.roomID) } } @@ -67,7 +74,23 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private var declineListenerHandle: TaskHandle? - init(callProvider: CXProviderProtocol? = nil, timeProvider: TimeProvider? = nil) { + /// The native call stack, for as long as there's a session running calls through it. + private var nativeCallStack: NativeCallStack? + private var nativeCallCancellable: AnyCancellable? + private var mediaProvider: MediaProviderProtocol? + + var nativeCallController: ElementCallController? { + nativeCallStack?.controller + } + + private var cancellables = Set() + + init(appSettings: AppSettings, + callProvider: CXProviderProtocol? = nil, + callController: CXCallControllerProtocol? = nil, + timeProvider: TimeProvider? = nil) { + self.appSettings = appSettings + self.callController = callController ?? CXCallController() pushRegistry = PKPushRegistry(queue: nil) self.timeProvider = timeProvider ?? TimeProvider(clock: ContinuousClock(), now: Date.init) @@ -95,45 +118,130 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe pushRegistry.desiredPushTypes = [.voIP] self.callProvider.setDelegate(self, queue: nil) + + appSettings.nativeCallEnabledPublisher + .sink { [weak self] _ in + self?.updateNativeCallStack() + } + .store(in: &cancellables) } - func setClientProxy(_ clientProxy: any ClientProxyProtocol) { - self.clientProxy = clientProxy + func setUserSession(_ userSession: UserSessionProtocol?) { + // A stack is built against one client proxy and can't be handed to another, which is what a + // soft logout or a cache clear brings: a new session without the sign out that drops this one. + if userSession?.clientProxy !== clientProxy { + stopNativeCallStack() + } + + clientProxy = userSession?.clientProxy + mediaProvider = userSession?.mediaProvider + + updateNativeCallStack() } - func setupCallSession(roomID: String, roomDisplayName: String) async { + @discardableResult + func handleNativeCallRequest(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool) -> Bool { + guard let nativeCallStack else { + return false + } + + nativeCallStack.handleCallRequest(roomProxy: roomProxy, isVoiceCall: isVoiceCall) + return true + } + + func minimizeNativeCall() { + nativeCallStack?.minimize() + } + + func restoreNativeCall() { + nativeCallStack?.restore() + } + + func setupCallSession(roomID: String, roomDisplayName: String, isVideo: Bool) async { + // Unlike the push handling below, this always runs with a session, so the stack itself is + // the answer rather than the setting that would have built one. + let isNative = nativeCallStack != nil + MXLog.info("Setting up a call session, native: \(isNative), video: \(isVideo), replacing an ongoing call: \(ongoingCallID != nil)") + // Drop any ongoing calls when starting a new one if ongoingCallID != nil { - tearDownCallSession() + tearDownCallSession(sendEndCallAction: true) } // If this starting from a ring reuse those identifiers // Make sure the roomID matches - let callID = if let incomingCallID, incomingCallID.roomID == roomID { + var callID = if let incomingCallID, incomingCallID.roomID == roomID { incomingCallID } else { - CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: nil, isVoiceCall: false) + CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: nil, isVoiceCall: !isVideo) } + callID.isNative = isNative + let isAnsweringIncomingCall = callID.rtcNotificationID != nil clearIncomingCallState() ongoingCallID = callID - // Don't bother starting another CallKit session as it won't work properly + // A web view call must not be tracked by CallKit, as that gives this process exclusive + // access to media and the web view runs in another (see `CXAnswerCallAction`). // https://developer.apple.com/forums//thread/767949?answerId=812951022#812951022 + guard isNative else { + return + } - // let handle = CXHandle(type: .generic, value: roomDisplayName) - // let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle) - // startCallAction.isVideo = true + // Answering the ringing call: it is already reported, keep it. + guard !isAnsweringIncomingCall else { + return + } - // do { - // try await callController.request(CXTransaction(action: startCallAction)) - // } catch { - // MXLog.error("Failed requesting start call action with error: \(error)") - // } + let handle = CXHandle(type: .generic, value: roomID) + let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle) + startCallAction.contactIdentifier = roomDisplayName + startCallAction.isVideo = isVideo + + // Awaited because our membership goes out straight after: a peer that sees it before the + // system knows about the call reads the call as answered elsewhere. + await requestTransaction(CXTransaction(action: startCallAction), describedAs: "start call") + + // Without this the system call UI shows the handle, i.e. the room ID. + let update = CXCallUpdate() + update.localizedCallerName = roomDisplayName + update.remoteHandle = handle + update.hasVideo = isVideo + callProvider.reportCall(with: callID.callKitID, updated: update) } - func tearDownCallSession() { - tearDownCallSession(sendEndCallAction: true) + func reportCallSessionConnected(roomID: String) { + guard let ongoingCallID, ongoingCallID.roomID == roomID else { + // The system ends an outgoing call that never reports connecting, so a silent guard + // here shows up much later as a call that hangs itself up. + MXLog.warning("Not reporting the call as connected, no matching ongoing call") + return + } + + guard ongoingCallID.isNative else { + MXLog.info("Not reporting a web view call as connected, CallKit doesn't track it") + return + } + + MXLog.info("Reporting the call as connected") + callProvider.reportOutgoingCall(with: ongoingCallID.callKitID, connectedAt: nil) + } + + func tearDownCallSession(roomID: String) { + if ongoingCallID?.roomID == roomID { + tearDownCallSession(sendEndCallAction: true) + return + } + + // Answered and handed over, but the stack couldn't take it. Ending it here rather than + // waiting for the watchdog takes the system call UI away straight away. + if answeredNativeCallID?.roomID == roomID { + MXLog.warning("Ending an answered call the native call stack didn't take over") + endPendingCall() + return + } + + MXLog.info("Not tearing down the call session, no call for room \(roomID)") } func setAudioEnabled(_ enabled: Bool, roomID: String) { @@ -155,6 +263,57 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe } } + // MARK: - Native calls + + /// Builds or releases the call stack to match the session and the setting. + private func updateNativeCallStack() { + guard appSettings.nativeCallEnabled, let clientProxy else { + stopNativeCallStack() + return + } + + guard nativeCallStack == nil else { return } + + guard let transport = clientProxy.makeNativeCallTransport() else { + MXLog.error("Cannot start the native call stack without a transport") + return + } + + let style = ElementCallStyle(theme: NativeCallTheme(), + icons: NativeCallIcons(), + avatars: NativeCallAvatars(mediaProvider: mediaProvider), + strings: .init(you: L10n.commonYou, + error: L10n.commonError, + stop: L10n.actionStop, + back: L10n.actionBack)) + + // The stats overlay is raw RTP counters, so it follows whatever already reveals developer + // surface. Screen sharing stays off until the broadcast extension that makes it work lands. + let options = ElementCallOptions(isDeveloperModeEnabled: appSettings.developerOptionsEnabled) + + let stack = NativeCallStack(transport: transport, + system: NativeCallSystem(service: self), + options: options, + style: style) + nativeCallStack = stack + + nativeCallCancellable = stack.actions + .sink { [weak self] presentation in + self?.actionsSubject.send(.nativeCall(presentation)) + } + + Task { await stack.start() } + } + + private func stopNativeCallStack() { + guard let nativeCallStack else { return } + self.nativeCallStack = nil + nativeCallCancellable = nil + + nativeCallStack.stop() + actionsSubject.send(.nativeCall(.dismiss)) + } + // MARK: - PKPushRegistryDelegate func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { } @@ -215,7 +374,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe // If not for audio call the app will not be put to foreground and the webview won't be able to handle the call... // Consequence: The call will be presented to the user as a video call in CallKit UI, // but once Element Call is launched it will correctly route to a voice-only call. - update.hasVideo = true + update.hasVideo = appSettings.nativeCallEnabled ? !isVoiceCall : true update.localizedCallerName = roomDisplayName // https://stackoverflow.com/a/41230020/730924 update.remoteHandle = .init(type: .generic, value: roomID) @@ -253,10 +412,19 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { MXLog.info("Call provider did activate audio session") + actionsSubject.send(.audioSessionActivated) } func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { MXLog.info("Call provider did deactivate audio session") + actionsSubject.send(.audioSessionDeactivated) + } + + func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + // The native controller configured the audio session already; CallKit activates it and + // reports back through `didActivate`. + action.fulfill() + callProvider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) } func providerDidReset(_ provider: CXProvider) { @@ -287,6 +455,15 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe // First fullfill the action action.fulfill() + if appSettings.nativeCallEnabled { + // The native stack owns media in this process, so the CallKit call stays up: the + // controller attaches to it in `setupCallSession`. + endUnansweredCallTask?.cancel() + endCallIfLeftPending(incomingCallID) + actionsSubject.send(.startCall(roomID: incomingCallID.roomID, isVoiceCall: incomingCallID.isVoiceCall)) + return + } + // And delay ending the call so that the app has enough time // to get deeplinked into DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { @@ -302,7 +479,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe if let ongoingCallID { actionsSubject.send(.setAudioEnabled(!action.isMuted, roomID: ongoingCallID.roomID)) } else { - MXLog.error("Failed muting/unmuting call, missing ongoingCallID") + MXLog.info("Ignoring a mute action without an ongoing call") } action.fulfill() @@ -313,30 +490,56 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe // This gets called for no reason on simulators, where CallKit // isn't even supported, ignore it. #else - if let ongoingCallID { - actionsSubject.send(.endCall(roomID: ongoingCallID.roomID)) - } - - if let incomingCallID { - Task { - await sendDeclineCallEvent(incomingCallID) - } - } - - if incomingCallID?.callKitID == action.callUUID { - clearIncomingCallState() - } - - tearDownCallSession(sendEndCallAction: false) - + endCall(withCallKitID: action.callUUID) action.fulfill() #endif } + /// Ends the call the system identified, whether that's the ongoing one, the ringing one, or + /// neither because it has already been replaced. + /// + /// Kept out of the delegate method so that it's reachable on the simulator, where the provider + /// performs end call actions of its own and the delegate has to ignore them. + func endCall(withCallKitID callKitID: UUID) { + MXLog.info("Ending the call for \(callKitID), ongoing: \(ongoingCallID != nil), incoming: \(incomingCallID != nil)") + + // Both branches are gated on the identifier. Leaving a call to start one in another room + // requests an end call action for the old call and replaces `ongoingCallID` before the + // system gets round to performing it, so an ungated handler reads it as the new call ending + // and hangs up the call that was just started. + if let ongoingCallID, ongoingCallID.callKitID == callKitID { + actionsSubject.send(.endCall(roomID: ongoingCallID.roomID)) + tearDownCallSession(sendEndCallAction: false) + } + + if let incomingCallID, incomingCallID.callKitID == callKitID { + Task { + await sendDeclineCallEvent(incomingCallID) + } + + clearIncomingCallState() + } + } + // MARK: - Private + /// Bridges the controller's completion handler, which is all ``CXCallControllerProtocol`` can + /// carry, back to an awaitable call. + private func requestTransaction(_ transaction: CXTransaction, describedAs description: String) async { + await withCheckedContinuation { continuation in + callController.request(transaction) { error in + if let error { + MXLog.error("Failed requesting \(description) action with error: \(error)") + } + + continuation.resume() + } + } + } + private func tearDownCallSession(sendEndCallAction: Bool = true) { if sendEndCallAction, let ongoingCallID { + MXLog.info("Requesting an end call action for the ongoing call") let transaction = CXTransaction(action: CXEndCallAction(call: ongoingCallID.callKitID)) callController.request(transaction) { error in if let error { @@ -381,6 +584,32 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe } } + /// Ends an answered call that the native stack never took over, which nothing else would: the + /// call is deliberately left up for the controller to attach to in `setupCallSession`. + private func endCallIfLeftPending(_ callID: CallID) { + answeredNativeCallID = callID + + endPendingCallTask = Task { [weak self] in + try? await self?.timeProvider.clock.sleep(for: .seconds(30)) + + guard let self, !Task.isCancelled, answeredNativeCallID == callID else { + return + } + + MXLog.error("The native call stack never took over the answered call, ending it") + endPendingCall() + } + } + + private func endPendingCall() { + guard let answeredNativeCallID else { + return + } + + callProvider.reportCall(with: answeredNativeCallID.callKitID, endedAt: nil, reason: .failed) + clearIncomingCallState() + } + private func sendDeclineCallEvent(_ incomingCallID: CallID) async { guard let rtcNotificationID = incomingCallID.rtcNotificationID else { MXLog.info("No rtc notification event to decline.") @@ -482,6 +711,9 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private func clearIncomingCallState() { endUnansweredCallTask?.cancel() endUnansweredCallTask = nil + endPendingCallTask?.cancel() + endPendingCallTask = nil + answeredNativeCallID = nil declineListenerHandle?.cancel() declineListenerHandle = nil incomingCallRoomInfoCancellable = nil diff --git a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift index f691e8284..7f98a5c05 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift @@ -7,25 +7,64 @@ // import Combine +import ElementCall + +/// What the host has to do about a native call, beyond running it. +enum NativeCallPresentation: Equatable { + case present + case restore + case minimize + case dismiss +} enum ElementCallServiceAction { case receivedIncomingCallRequest case startCall(roomID: String, isVoiceCall: Bool) case endCall(roomID: String) case setAudioEnabled(_ enabled: Bool, roomID: String) + /// CallKit activated the audio session: a native call may start its audio engine now. + case audioSessionActivated + case audioSessionDeactivated + /// The native call's screen needs presenting, minimizing, restoring or dismissing. + case nativeCall(NativeCallPresentation) } // sourcery: AutoMockable -protocol ElementCallServiceProtocol { +protocol ElementCallServiceProtocol: AnyObject { var actions: AnyPublisher { get } var ongoingCallRoomIDPublisher: CurrentValuePublisher { get } - func setClientProxy(_ clientProxy: ClientProxyProtocol) + /// The controller for this session's native call stack, `nil` when native calls are unavailable. + var nativeCallController: ElementCallController? { get } - func setupCallSession(roomID: String, roomDisplayName: String) async + /// Builds the native call stack for the session, or releases it along with the to-device + /// subscription it holds open. Pass `nil` when signing out. + func setUserSession(_ userSession: UserSessionProtocol?) - func tearDownCallSession() + /// Starts, or returns to, a native call in the room, driving its screen through + /// ``ElementCallServiceAction/nativeCall(_:)``. + /// + /// Returns `false` when there's no native call stack and the caller should present the Element + /// Call web view instead. + @discardableResult + func handleNativeCallRequest(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool) -> Bool + + /// Asks the native call to minimize. The screen only comes down once a system window has + /// started, which arrives as ``ElementCallServiceAction/nativeCall(_:)``. + func minimizeNativeCall() + + /// Brings the native call back out of its system window. + func restoreNativeCall() + + /// Registers the call with CallKit, adopting the ringing incoming call for the room if there is one. + func setupCallSession(roomID: String, roomDisplayName: String, isVideo: Bool) async + + /// Tells CallKit the call is connected. Does nothing for web view calls, which CallKit never tracks. + func reportCallSessionConnected(roomID: String) + + /// Ends the call for the room, including an answered call the native stack never took over. + func tearDownCallSession(roomID: String) func setAudioEnabled(_ enabled: Bool, roomID: String) } diff --git a/ElementX/Sources/Services/NativeCall/NativeCallRoomContext.swift b/ElementX/Sources/Services/NativeCall/NativeCallRoomContext.swift new file mode 100644 index 000000000..e51fad9f0 --- /dev/null +++ b/ElementX/Sources/Services/NativeCall/NativeCallRoomContext.swift @@ -0,0 +1,58 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import ElementCall + +/// Serves the call package's room port from a room proxy. +/// +/// Built on the proxy rather than in the package so that the call reuses its caching: current values +/// for room info and members, refreshed off the SDK's subscription, read from disk before the network. +final class NativeCallRoomContext: ElementCallRoomContextProtocol { + private let roomProxy: JoinedRoomProxyProtocol + + init(roomProxy: JoinedRoomProxyProtocol) { + self.roomProxy = roomProxy + } + + var roomID: String { + roomProxy.id + } + + var displayName: String { + roomProxy.infoPublisher.value.displayNameOrID + } + + var displayNamePublisher: AnyPublisher { + roomProxy.infoPublisher + .map(\.displayNameOrID) + .removeDuplicates() + .eraseToAnyPublisher() + } + + var isDirect: Bool { + roomProxy.infoPublisher.value.isDirect + } + + var isDirectPublisher: AnyPublisher { + roomProxy.infoPublisher.map(\.isDirect).removeDuplicates().eraseToAnyPublisher() + } + + var memberProfiles: [String: ElementCallMemberProfile] { + Self.profiles(from: roomProxy.membersPublisher.value) + } + + var memberProfilesPublisher: AnyPublisher<[String: ElementCallMemberProfile], Never> { + roomProxy.membersPublisher.map(Self.profiles).eraseToAnyPublisher() + } + + private static func profiles(from members: [RoomMemberProxyProtocol]) -> [String: ElementCallMemberProfile] { + Dictionary(members.map { ($0.userID, ElementCallMemberProfile(userID: $0.userID, + displayName: $0.displayName, + avatarURL: $0.avatarURL)) }) { first, _ in first } + } +} diff --git a/ElementX/Sources/Services/NativeCall/NativeCallStack.swift b/ElementX/Sources/Services/NativeCall/NativeCallStack.swift new file mode 100644 index 000000000..af05fa928 --- /dev/null +++ b/ElementX/Sources/Services/NativeCall/NativeCallStack.swift @@ -0,0 +1,168 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import ElementCall +import Foundation + +/// The call package's stack for one logged-in session, turning what the call does into the +/// navigation the host owes it. +/// +/// Built with the session rather than with the first call: the core's to-device subscription has no +/// catch-up, so a stack that starts only once a call does can miss the keys sent while it was +/// joining. +@MainActor +final class NativeCallStack { + /// The call itself, for the screen that is built from it. Everything else asks this type. + var controller: ElementCallController { + stack.controller + } + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + private let stack: ElementCallStack + private let actionsSubject = PassthroughSubject() + private var cancellables = Set() + + /// A call requested while another one was still running, started once that one has ended. + private var pendingCallRequest: (roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)? + + init(transport: any ElementCallMatrixTransportProtocol, + system: any ElementCallSystemProvidingProtocol, + options: ElementCallOptions, + style: ElementCallStyle) { + stack = ElementCallStack(transport: transport, + system: system, + options: options, + style: style, + logger: Logger()) + + stack.controller.actions + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + self?.handle(action) + } + .store(in: &cancellables) + } + + func start() async { + Self.installRTCLogBridge() + await stack.start() + } + + /// Releases the stack along with the to-device subscription it holds open for key delivery. + func stop() { + MXLog.info("Stopping the native call stack, in a call: \(controller.isInCall)") + + // Best effort: the leave this starts may not finish before the core stops, but the + // alternative is walking away from the call without telling the room at all. + if controller.isInCall { + controller.hangUp() + } + + stack.stop() + } + + /// Starts, or returns to, the call in the room. + func handleCallRequest(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool) { + if controller.isInCall { + guard controller.room?.roomID != roomProxy.id else { + // Reached while the call is still joining, before the service has an ongoing call + // for its own room check to match against. + MXLog.info("Returning to the call already starting in this room.") + actionsSubject.send(.restore) + return + } + + // The controller ignores a second call, so wait for the running one to leave its room. + MXLog.info("Leaving the ongoing call to start the one requested in another room.") + pendingCallRequest = (roomProxy, isVoiceCall) + controller.hangUp() + return + } + + // Starting rings the room; joining one already running happens quietly. + let callData = ElementCallData(isAudioCall: isVoiceCall, + isStartingCall: !roomProxy.infoPublisher.value.hasRoomCall) + controller.startCall(callData, room: NativeCallRoomContext(roomProxy: roomProxy)) + actionsSubject.send(.present) + } + + /// Asks the call to minimize. The screen only comes down once the controller says a system + /// window has started, which it answers through ``actions``. + func minimize() { + controller.requestMinimize() + } + + func restore() { + controller.restore() + } + + // MARK: - Private + + private func handle(_ action: ElementCallControllerAction) { + switch action { + case .minimizeRequested: + // Nothing to do until we know what the call is minimizing into: the controller follows + // this with either window or no window. + break + case .restoreRequested: + actionsSubject.send(.restore) + case .ended: + actionsSubject.send(.dismiss) + controller.reset() + startPendingCall() + case .pictureInPictureStarted: + // Also reached when the system started the window on backgrounding, so this isn't + // necessarily a minimize we asked for. + actionsSubject.send(.minimize) + case .pictureInPictureUnavailable: + // Hiding the screen without a window to minimize into would leave the call running with + // no way back to mute or hang up. + MXLog.info("Staying on the call screen: no system window is available.") + actionsSubject.send(.restore) + } + } + + private func startPendingCall() { + guard let pendingCallRequest else { return } + self.pendingCallRequest = nil + handleCallRequest(roomProxy: pendingCallRequest.roomProxy, isVoiceCall: pendingCallRequest.isVoiceCall) + } + + /// Sends the call package's log lines to `MXLog`, keeping the package's own file and line so + /// entries point at its source rather than at here. + private struct Logger: ElementCallLoggingProtocol { + func log(_ record: ElementCallLogRecord) { + switch record.level { + case .debug: MXLog.debug(record.message, file: record.file, line: record.line) + case .info: MXLog.info(record.message, file: record.file, line: record.line) + case .warning: MXLog.warning(record.message, file: record.file, line: record.line) + case .error: MXLog.error(record.message, file: record.file, line: record.line) + } + } + } + + /// Feeds the Rust RTC core's own records into `MXLog` too, so they end up in rageshakes next to + /// the SDK's. Idempotent, and must run before anything else uses the call package. + private static func installRTCLogBridge() { + MatrixRTCLogging.install { record in + // The core's own position when it has one, its module path otherwise. + let file = record.file ?? record.target + let line = Int(record.line ?? 0) + switch record.level { + case .error: MXLog.error(record.message, file: file, line: line) + case .warning: MXLog.warning(record.message, file: file, line: line) + case .info: MXLog.info(record.message, file: file, line: line) + case .debug: MXLog.debug(record.message, file: file, line: line) + case .verbose: MXLog.verbose(record.message, file: file, line: line) + } + } + } +} diff --git a/ElementX/Sources/Services/NativeCall/NativeCallStyle.swift b/ElementX/Sources/Services/NativeCall/NativeCallStyle.swift new file mode 100644 index 000000000..5a11ac5cc --- /dev/null +++ b/ElementX/Sources/Services/NativeCall/NativeCallStyle.swift @@ -0,0 +1,152 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import ElementCall +import SwiftUI + +// Colours, icons and avatars for the call, read from this app's Compound. +// +// Why the package takes a theme instead of linking Compound itself: `Color.compound` is a single +// shared instance that Element Pro re-brands at runtime, and a second copy inside the package would +// never see the override. Every member is computed so each read goes to the live instance. + +struct NativeCallTheme: ElementCallThemeProtocol { + var bgCanvasDefault: Color { + .compound.bgCanvasDefault + } + + var bgCanvasDefaultLevel: Color { + .compound.bgCanvasDefaultLevel1 + } + + var bgSubtleSecondary: Color { + .compound.bgSubtleSecondary + } + + var bgAccentRest: Color { + .compound.bgAccentRest + } + + var bgActionPrimaryRest: Color { + .compound.bgActionPrimaryRest + } + + var bgCriticalPrimary: Color { + .compound.bgCriticalPrimary + } + + var iconPrimary: Color { + .compound.iconPrimary + } + + var iconQuaternary: Color { + .compound.iconQuaternary + } + + var iconAccentPrimary: Color { + .compound.iconAccentPrimary + } + + var iconCriticalPrimary: Color { + .compound.iconCriticalPrimary + } + + var iconOnSolidPrimary: Color { + .compound.iconOnSolidPrimary + } + + var textPrimary: Color { + .compound.textPrimary + } + + var textSecondary: Color { + .compound.textSecondary + } + + var textCriticalPrimary: Color { + .compound.textCriticalPrimary + } + + var borderSuccessSubtle: Color { + .compound.borderSuccessSubtle + } + + var borderInteractiveSecondary: Color { + .compound.borderInteractiveSecondary + } + + var bodySM: Font { + .compound.bodySM + } + + var bodySMSemibold: Font { + .compound.bodySMSemibold + } + + var bodyLGSemibold: Font { + .compound.bodyLGSemibold + } +} + +struct NativeCallIcons: ElementCallIconRenderingProtocol { + func icon(_ icon: ElementCallIcon, size: ElementCallIconSize, relativeTo textStyle: ElementCallTextStyle?) -> AnyView { + // The package asks for a text style rather than a point size so CompoundIcon can scale it. + AnyView(CompoundIcon(Self.keyPath(for: icon), size: Self.size(for: size), relativeTo: Self.font(for: textStyle))) + } + + private static func keyPath(for icon: ElementCallIcon) -> KeyPath { + switch icon { + case .endCall: \.endCall + case .micOn: \.micOnSolid + case .micOff: \.micOffSolid + case .videoCall: \.videoCallSolid + case .videoCallOff: \.videoCallOffSolid + case .switchCamera: \.switchCameraSolid + case .shareScreen: \.shareScreenSolid + case .volumeOn: \.volumeOnSolid + case .volumeOff: \.volumeOffSolid + case .raisedHand: \.raisedHandSolid + case .userProfile: \.userProfileSolid + case .overflow: \.overflowHorizontal + case .collapse: \.collapse + } + } + + private static func size(for size: ElementCallIconSize) -> CompoundIcon.Size { + switch size { + case .xSmall: .xSmall + case .small: .small + case .medium: .medium + } + } + + // Not redundant despite the target's default isolation: a static member of a type conforming to + // a nonisolated, Sendable port is nonisolated, and `Font.compound` is main actor-isolated. + @MainActor + private static func font(for textStyle: ElementCallTextStyle?) -> Font { + switch textStyle { + case .bodySM: .compound.bodySM + case .bodySMSemibold: .compound.bodySMSemibold + case .bodyLGSemibold: .compound.bodyLGSemibold + case nil: .compound.bodyMD + } + } +} + +/// Keeps avatars looking like the rest of the app, and keeps media loading out of the package. +struct NativeCallAvatars: ElementCallAvatarRenderingProtocol { + let mediaProvider: MediaProviderProtocol? + + func avatar(userID: String, displayName: String?, avatarURL: URL?, size: ElementCallAvatarSize) -> AnyView { + AnyView(LoadableAvatarImage(url: avatarURL, + name: displayName, + contentID: userID, + avatarSize: size == .thumbnail ? .user(on: .roomDetails) : .user(on: .memberDetails), + mediaProvider: mediaProvider)) + } +} diff --git a/ElementX/Sources/Services/NativeCall/NativeCallSystem.swift b/ElementX/Sources/Services/NativeCall/NativeCallSystem.swift new file mode 100644 index 000000000..f26f11d38 --- /dev/null +++ b/ElementX/Sources/Services/NativeCall/NativeCallSystem.swift @@ -0,0 +1,65 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import ElementCall + +/// Serves the call package's system-call port from `ElementCallService`. +/// +/// Stays app-side because the same `CXProvider` and VoIP push registry serve the web-view call path +/// too, so ownership can't be handed to the package. +final class NativeCallSystem: ElementCallSystemProvidingProtocol { + /// Weak because the service owns the call stack, which owns the controller holding this port. + private weak var service: ElementCallServiceProtocol? + + /// Stored so the events keep flowing off the subject itself, without reaching back through the + /// weak reference on every subscription. + private let serviceActions: AnyPublisher + + init(service: ElementCallServiceProtocol) { + self.service = service + serviceActions = service.actions + } + + var events: AnyPublisher { + serviceActions + .compactMap { action in + switch action { + case .audioSessionActivated: + .audioSessionActivated + case .audioSessionDeactivated: + .audioSessionDeactivated + case .setAudioEnabled(let enabled, _): + // The system reports what the microphone should be; the port speaks in mutes. + .microphoneMuteChanged(isMuted: !enabled) + case .endCall(let roomID): + .endCallRequested(roomID: roomID) + // All navigation, handled by the flow coordinator. The call reaches this port later, + // as an ordinary start. + case .startCall, .receivedIncomingCallRequest, .nativeCall: + nil + } + } + .eraseToAnyPublisher() + } + + func startCall(roomID: String, displayName: String, isVideo: Bool) async { + await service?.setupCallSession(roomID: roomID, roomDisplayName: displayName, isVideo: isVideo) + } + + func reportConnected(roomID: String) { + service?.reportCallSessionConnected(roomID: roomID) + } + + func endCall(roomID: String) { + service?.tearDownCallSession(roomID: roomID) + } + + func setMicrophoneEnabled(_ enabled: Bool, roomID: String) { + service?.setAudioEnabled(enabled, roomID: roomID) + } +} diff --git a/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift index b21e84880..7e501dab0 100644 --- a/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift @@ -123,6 +123,11 @@ extension RoomInfoProxyProtocol { isDirect && activeMembersCount == 1 } + /// The room's name, falling back to its identifier when it doesn't have one. + var displayNameOrID: String { + displayName ?? id + } + /// Find the first alias that matches the given homeserver /// - Parameters: /// - serverName: the homserver in question diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 4b16c08c1..9c17e77b1 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -260,6 +260,8 @@ targets: implicit: true link: false embed: true + - package: ElementCall + product: ElementCall - package: PostHog - package: SwiftState - package: GZIP diff --git a/Tools/Sourcery/AutoMockable.stencil b/Tools/Sourcery/AutoMockable.stencil index 5d890792a..76ea069e5 100644 --- a/Tools/Sourcery/AutoMockable.stencil +++ b/Tools/Sourcery/AutoMockable.stencil @@ -7,6 +7,7 @@ import AnalyticsEvents import AVFoundation import CallKit +import ElementCall import Foundation import LocalAuthentication import Photos diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift index deadcfbf5..1c04e6b78 100644 --- a/UnitTests/Sources/ElementCallServiceTests.swift +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -6,34 +6,46 @@ // import CallKit +import ElementCall @testable import ElementX import PushKit import Testing @MainActor final class ElementCallServiceTests { + private var appSettings: AppSettings! private var callProvider: CXProviderMock! + private var callController: CXCallControllerMock! private var currentDate: Date! private var clock: ManualClock! private var pushRegistry: PKPushRegistry! private var service: ElementCallService! + private var userSession: UserSessionMock! init() { + appSettings = AppSettings.volatile() pushRegistry = PKPushRegistry(queue: nil) callProvider = CXProviderMock(.init()) + callController = CXCallControllerMock(.init()) currentDate = Date() clock = ManualClock() let dateProvider: () -> Date = { self.currentDate } - service = ElementCallService(callProvider: callProvider, timeProvider: TimeProvider(clock: clock, now: dateProvider)) + service = ElementCallService(appSettings: appSettings, + callProvider: callProvider, + callController: callController, + timeProvider: TimeProvider(clock: clock, now: dateProvider)) } isolated deinit { + appSettings = nil callProvider = nil + callController = nil currentDate = nil clock = nil pushRegistry = nil + userSession = nil } @Test @@ -182,7 +194,7 @@ final class ElementCallServiceTests { } @Test - func setupCallSessionCancelsPendingUnansweredTimeout() async { + func setupCallSessionCancelsPendingUnansweredTimeout() async throws { // Schedule the 60s unanswered timer via an incoming push await waitForConfirmation { confirmation in let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60) @@ -196,22 +208,20 @@ final class ElementCallServiceTests { // Simulate the answer flow handing off to setupCallSession, which must cancel // the pending endUnansweredCallTask as part of clearing the incoming state. - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome") + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) - var unansweredFired = false + let (endedCalls, endedCallsContinuation) = AsyncStream.makeStream() callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in - if reason == .unanswered { - unansweredFired = true - } + endedCallsContinuation.yield(reason) } + let deferredUnansweredCall = deferFailure(endedCalls, + timeout: .milliseconds(100), + message: "endUnansweredCallTask should have been cancelled by setupCallSession") { $0 == .unanswered } // Advance past what would have been the 60s unanswered timeout clock.advance(by: .seconds(120)) - for _ in 0..<3 { - await Task.yield() - } - #expect(!unansweredFired, "endUnansweredCallTask should have been cancelled by setupCallSession") + try await deferredUnansweredCall.fulfill() } @Test @@ -229,7 +239,7 @@ final class ElementCallServiceTests { @Test func duplicateRoomPushReportsCallAsHandled() async { // A duplicate push for an ongoing call is reported as handled, leaving the ongoing call alone. - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome") + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30) await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .answeredElsewhere) @@ -240,6 +250,251 @@ final class ElementCallServiceTests { #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") } + @Test + func webViewCallSessionIsNotTrackedByCallKit() async { + // CallKit tracking a call gives this process exclusive media access, which starves the web + // view. So a web view call is only ever tracked on the app side. + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + service.reportCallSessionConnected(roomID: "!room:example.com") + + #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") + #expect(!callProvider.reportCallWithUpdatedCalled) + #expect(!callProvider.reportOutgoingCallWithConnectedAtCalled) + } + + @Test + func endingAReplacedCallLeavesTheCallThatReplacedItUp() async throws { + enableNativeCalls() + + // Leaving a call to start one in another room requests an end call action for the first, + // then starts the second before the system performs it. + await service.setupCallSession(roomID: "!first:example.com", roomDisplayName: "first", isVideo: true) + let firstCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid) + + service.tearDownCallSession(roomID: "!first:example.com") + await service.setupCallSession(roomID: "!second:example.com", roomDisplayName: "second", isVideo: true) + + var endedRooms: [String] = [] + let cancellable = service.actions.sink { action in + if case .endCall(let roomID) = action { + endedRooms.append(roomID) + } + } + defer { cancellable.cancel() } + + service.endCall(withCallKitID: firstCallID) + + #expect(endedRooms.isEmpty, "The first call's end action must not hang up the second call") + #expect(service.ongoingCallRoomIDPublisher.value == "!second:example.com") + + // And the second call still ends when it's the one the system means. + let secondCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid) + service.endCall(withCallKitID: secondCallID) + + #expect(endedRooms == ["!second:example.com"]) + #expect(service.ongoingCallRoomIDPublisher.value == nil) + } + + @Test + func tearingDownAnotherRoomLeavesTheOngoingCallUp() async { + // A call screen that is dismissed after another room's call replaced it must not take the + // new call down with it. + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + + service.tearDownCallSession(roomID: "!other:example.com") + #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") + + service.tearDownCallSession(roomID: "!room:example.com") + #expect(service.ongoingCallRoomIDPublisher.value == nil) + } + + // MARK: - Native calls + + @Test + func nativeModeReportsVoiceCallsWithoutVideo() async { + appSettings.nativeCallEnabled = true + + await reportIncomingPush(isVoiceCall: true) + + #expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == false) + } + + @Test + func nativeModeStillReportsVideoCallsWithVideo() async { + appSettings.nativeCallEnabled = true + + await reportIncomingPush(isVoiceCall: false) + + #expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == true) + } + + @Test + func answeringInNativeModeKeepsTheCallUp() async throws { + appSettings.nativeCallEnabled = true + await reportIncomingPush(isVoiceCall: false) + await clock.waitForScheduledSleep() + + try await answerReportedCall() + + // The webview path ends the call a second after answering so that `setupCallSession` can + // start a new one. The native stack takes this one over instead, so it has to survive. + #expect(!callProvider.reportCallWithEndedAtReasonCalled) + } + + @Test + func answeredNativeCallEndsWhenNothingTakesItOver() async throws { + appSettings.nativeCallEnabled = true + await reportIncomingPush(isVoiceCall: false) + await clock.waitForScheduledSleep() + + let answeredUUID = try await answerReportedCall() + + let (endedCalls, endedCallsContinuation) = AsyncStream<(UUID, CXCallEndedReason)>.makeStream() + callProvider.reportCallWithEndedAtReasonClosure = { uuid, _, reason in + endedCallsContinuation.yield((uuid, reason)) + } + let deferredEndedCall = deferFulfillment(endedCalls) { _ in true } + + // Answering swaps the unanswered timer for the watchdog that catches a hand-off that never + // happens — the room isn't joined, there's no transport, a call is already running. + #expect(await clock.waitForScheduledSleep() == .seconds(30)) + clock.advance(by: .seconds(30)) + + let (uuid, reason) = try await deferredEndedCall.fulfill() + #expect(uuid == answeredUUID) + #expect(reason == .failed) + } + + @Test + func nativeCallStackTakesOverTheAnsweredCall() async throws { + enableNativeCalls() + await reportIncomingPush(isVoiceCall: false) + await clock.waitForScheduledSleep() + + try await answerReportedCall() + + // Answering starts the watchdog, so wait for it before asserting that the hand-over stops it. + await clock.waitForScheduledSleep() + + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + + // Taking the answered call over means keeping its identifier rather than reporting a new + // outgoing call, which is what the update below would be part of. + #expect(!callProvider.reportCallWithUpdatedCalled) + #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") + + // And the watchdog must be gone, otherwise it ends the call that was just taken over. + #expect(!clock.hasPendingSleep, "The watchdog must not outlive the hand-over") + } + + @Test + func nativeCallStackForAnotherRoomReportsANewCall() async throws { + enableNativeCalls() + await reportIncomingPush(isVoiceCall: false) + await clock.waitForScheduledSleep() + + let answeredUUID = try await answerReportedCall() + + await service.setupCallSession(roomID: "!other:example.com", roomDisplayName: "other", isVideo: true) + + let update = try #require(callProvider.reportCallWithUpdatedReceivedArguments) + #expect(update.uuid != answeredUUID) + #expect(update.update.localizedCallerName == "other") + #expect(service.ongoingCallRoomIDPublisher.value == "!other:example.com") + + // Unlike a web view call, a native one is tracked by CallKit, so it gets the connected report too. + service.reportCallSessionConnected(roomID: "!other:example.com") + #expect(callProvider.reportOutgoingCallWithConnectedAtReceivedArguments?.uuid == update.uuid) + } + + @Test + func nativeCallStackNeedsBothTheSettingAndASession() { + #expect(service.nativeCallController == nil) + + appSettings.nativeCallEnabled = true + #expect(service.nativeCallController == nil, "The setting alone has no transport to run a call over") + + service.setUserSession(nativeUserSession()) + #expect(service.nativeCallController != nil) + + service.setUserSession(nil) + #expect(service.nativeCallController == nil, "Signing out has to release the stack's to-device subscription") + } + + @Test + func turningTheSettingOnMidSessionBuildsTheStack() { + service.setUserSession(nativeUserSession()) + #expect(service.nativeCallController == nil) + + // The transport is built on demand, so the setting takes effect without an app restart. + appSettings.nativeCallEnabled = true + #expect(service.nativeCallController != nil) + } + + @Test + func aNewSessionGetsANewStack() { + appSettings.nativeCallEnabled = true + service.setUserSession(nativeUserSession()) + let firstController = service.nativeCallController + + // A soft logout or a cache clear brings a new session without the sign out that drops this + // one, and the stack is built against the client proxy it was given. + service.setUserSession(nativeUserSession()) + + #expect(service.nativeCallController !== firstController) + } + + @Test + func callsFallBackToTheWebViewWithoutAStack() { + let roomProxy = JoinedRoomProxyMock(.init(id: "!room:example.com")) + + #expect(!service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false)) + + appSettings.nativeCallEnabled = true + service.setUserSession(nativeUserSession()) + + #expect(service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false)) + } + + /// Builds a session whose client proxy can serve the native call stack, and holds on to it: the + /// service only keeps a weak reference, as the app owns the session. + private func nativeUserSession() -> UserSessionMock { + let clientProxy = ClientProxyMock(.init()) + clientProxy.makeNativeCallTransportReturnValue = ElementCallFakeTransport() + let session = UserSessionMock(.init(clientProxy: clientProxy)) + userSession = session + return session + } + + /// Puts the service in native mode, with the stack the CallKit paths check for. + private func enableNativeCalls() { + appSettings.nativeCallEnabled = true + service.setUserSession(nativeUserSession()) + } + + /// Delivers a VoIP push and waits for it to be reported, returning once the call is ringing. + private func reportIncomingPush(isVoiceCall: Bool) async { + await waitForConfirmation { confirmation in + let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60) + .updateIsVoice(isVoiceCall) + + service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: payload, for: .voIP) { + confirmation() + } + } + } + + /// Answers the call the provider last reported, returning its identifier. + @discardableResult + private func answerReportedCall() async throws -> UUID { + let uuid = try #require(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.uuid) + let provider = CXProvider(configuration: CXProviderConfiguration()) + + service.provider(provider, perform: CXAnswerCallAction(call: uuid)) + + return uuid + } + private func expectImmediatelyEndedCallReported(forPayload payload: PKPushPayloadMock, expectedReason: CXCallEndedReason) async { let baselineNewIncomingCount = callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount diff --git a/UnitTests/Sources/NativeCallStackTests.swift b/UnitTests/Sources/NativeCallStackTests.swift new file mode 100644 index 000000000..7d9824723 --- /dev/null +++ b/UnitTests/Sources/NativeCallStackTests.swift @@ -0,0 +1,110 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import ElementCall +@testable import ElementX +import Testing + +@MainActor +final class NativeCallStackTests { + private let stack: NativeCallStack + private var presentations: [NativeCallPresentation] = [] + private var cancellables = Set() + + init() { + stack = NativeCallStack(transport: ElementCallFakeTransport(), + system: ElementCallFakeSystem(), + options: ElementCallOptions(), + style: .stock) + + stack.actions + .sink { [weak self] presentation in + self?.presentations.append(presentation) + } + .store(in: &cancellables) + } + + @Test + func startingACallAsksForItsScreen() { + stack.handleCallRequest(roomProxy: roomProxy(id: "!room:example.com"), isVoiceCall: false) + + #expect(presentations == [.present]) + #expect(stack.controller.room?.roomID == "!room:example.com") + } + + @Test + func aCallStartsARoomWithoutOneAndJoinsARoomWithOne() { + stack.handleCallRequest(roomProxy: roomProxy(id: "!empty:example.com", hasOngoingCall: false), isVoiceCall: true) + // Starting rings the room; joining one already running happens quietly. + #expect(stack.controller.callData?.isStartingCall == true) + #expect(stack.controller.callData?.isAudioCall == true) + + stack.stop() + + let joining = NativeCallStack(transport: ElementCallFakeTransport(), + system: ElementCallFakeSystem(), + options: ElementCallOptions(), + style: .stock) + joining.handleCallRequest(roomProxy: roomProxy(id: "!busy:example.com", hasOngoingCall: true), isVoiceCall: true) + #expect(joining.controller.callData?.isStartingCall == false) + } + + @Test + func askingForTheCallYouAreAlreadyInRestoresItsScreen() { + let roomProxy = roomProxy(id: "!room:example.com") + stack.handleCallRequest(roomProxy: roomProxy, isVoiceCall: false) + presentations.removeAll() + + // Reached while the call is still joining, before the service has an ongoing call of its own. + stack.handleCallRequest(roomProxy: roomProxy, isVoiceCall: false) + + #expect(presentations == [.restore]) + } + + @Test + func aCallInAnotherRoomWaitsForTheRunningOneToEnd() async throws { + stack.handleCallRequest(roomProxy: roomProxy(id: "!first:example.com"), isVoiceCall: false) + presentations.removeAll() + + // The controller ignores a second call, so this one is queued behind a hang up. + stack.handleCallRequest(roomProxy: roomProxy(id: "!second:example.com"), isVoiceCall: false) + #expect(stack.controller.room?.roomID == "!first:example.com") + + try await waitForPresentations([.dismiss, .present]) + #expect(stack.controller.room?.roomID == "!second:example.com") + } + + @Test + func endingACallDismissesItsScreen() async throws { + stack.handleCallRequest(roomProxy: roomProxy(id: "!room:example.com"), isVoiceCall: false) + presentations.removeAll() + + stack.controller.hangUp() + + try await waitForPresentations([.dismiss]) + #expect(stack.controller.room == nil, "The controller is reset so the next call starts clean") + } + + // MARK: - Helpers + + private func roomProxy(id: String, hasOngoingCall: Bool = false) -> JoinedRoomProxyMock { + JoinedRoomProxyMock(.init(id: id, hasOngoingCall: hasOngoingCall)) + } + + private func waitForPresentations(_ expected: [NativeCallPresentation]) async throws { + let deferred = deferFulfillment(stack.actions) { [weak self] _ in + self?.presentations == expected + } + + if presentations == expected { + return + } + + try await deferred.fulfill() + } +} diff --git a/UnitTests/Sources/TestUtilities/ManualClock.swift b/UnitTests/Sources/TestUtilities/ManualClock.swift index 2d2dd31aa..0a5352d66 100644 --- a/UnitTests/Sources/TestUtilities/ManualClock.swift +++ b/UnitTests/Sources/TestUtilities/ManualClock.swift @@ -45,6 +45,12 @@ final nonisolated class ManualClock: Clock { state.withLock { $0.now } } + /// Whether a sleep is waiting on ``advance(by:)``, which is how a test asserts that a timer has + /// been cancelled rather than advancing the clock and watching for what it doesn't do. + var hasPendingSleep: Bool { + state.withLock { $0.sleep != nil } + } + /// Suspends until ``advance(by:)`` moves the clock to `deadline`, or the calling task is /// cancelled, in which case this throws a `CancellationError`. Deadlines that have already /// passed return without suspending. `tolerance` is ignored. diff --git a/project.yml b/project.yml index e039e0974..e20f84d51 100644 --- a/project.yml +++ b/project.yml @@ -54,6 +54,9 @@ settings: MARKETING_VERSION: 26.09.2 CURRENT_PROJECT_VERSION: 1 SUPPORTS_MACCATALYST: false + # The call package's media xcframework (libwebrtc) has no Intel simulator slice, and a package + # cannot declare this for the projects that consume it. + EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 include: - path: app.yml @@ -78,6 +81,10 @@ packages: # path: ../matrix-rust-sdk Compound: path: compound-ios + ElementCall: + url: https://github.com/element-hq/element-call-ios + exactVersion: 0.1.0-rc.6 + # path: ../element-call-ios BuildExtensions: path: Components/BuildExtensions AnalyticsEvents: