From fb49f5b83b30de8b8c52b3b467e9ca29357fc131 Mon Sep 17 00:00:00 2001 From: Valere Fedronic Date: Thu, 17 Sep 2026 18:42:18 +0200 Subject: [PATCH] Add experimental native calls behind a developer flag (#6159) Add the element-call-ios dependency and a developer option for native calls The package carries the call UI and the matrix-rust-rtc media stack, pinned by exact version the way the SDK is, with the local path kept commented out for working against a checkout. Its media xcframework has no Intel simulator slice and a package cannot say so for the projects that consume it, hence the excluded architecture at the project level. The developer option is off by default and nothing reads it yet. It is placed next to the Element Call URL override, which only applies to the web view that native calls replace. --- ElementX.xcodeproj/project.pbxproj | 115 +++-- .../xcshareddata/swiftpm/Package.resolved | 20 +- .../Sources/Application/AppCoordinator.swift | 7 +- .../Application/Settings/AppSettings.swift | 4 + .../UserSessionFlowCoordinator.swift | 89 +++- .../Sources/Mocks/CXCallControllerMock.swift | 17 + .../Mocks/ElementCallServiceMock.swift | 1 + .../Mocks/Generated/GeneratedMocks.swift | 395 +++++++++++++++--- .../Sources/Mocks/SDK/ClientSDKMock.swift | 5 +- .../CallScreen/CallScreenViewModel.swift | 5 +- .../NativeCallScreenCoordinator.swift | 26 ++ .../RoomScreen/RoomScreenViewModel.swift | 4 +- .../DeveloperOptionsScreenModels.swift | 2 + .../View/DeveloperOptionsScreen.swift | 9 +- .../SpaceAddRoomsScreenModels.swift | 2 +- .../ThreadTimelineScreenViewModel.swift | 4 +- .../Sources/Services/Client/ClientProxy.swift | 5 + .../Services/Client/ClientProxyProtocol.swift | 4 + .../CXCallControllerProtocol.swift | 15 + .../ElementCall/CXProviderProtocol.swift | 3 + .../ElementCall/ElementCallService.swift | 306 ++++++++++++-- .../ElementCallServiceProtocol.swift | 47 ++- .../NativeCall/NativeCallRoomContext.swift | 58 +++ .../Services/NativeCall/NativeCallStack.swift | 168 ++++++++ .../Services/NativeCall/NativeCallStyle.swift | 152 +++++++ .../NativeCall/NativeCallSystem.swift | 65 +++ .../Services/Room/RoomInfoProxyProtocol.swift | 5 + ElementX/SupportingFiles/target.yml | 2 + Tools/Sourcery/AutoMockable.stencil | 1 + .../Sources/ElementCallServiceTests.swift | 279 ++++++++++++- UnitTests/Sources/NativeCallStackTests.swift | 110 +++++ .../Sources/TestUtilities/ManualClock.swift | 6 + project.yml | 7 + 33 files changed, 1778 insertions(+), 160 deletions(-) create mode 100644 ElementX/Sources/Mocks/CXCallControllerMock.swift create mode 100644 ElementX/Sources/Screens/NativeCallScreen/NativeCallScreenCoordinator.swift create mode 100644 ElementX/Sources/Services/ElementCall/CXCallControllerProtocol.swift create mode 100644 ElementX/Sources/Services/NativeCall/NativeCallRoomContext.swift create mode 100644 ElementX/Sources/Services/NativeCall/NativeCallStack.swift create mode 100644 ElementX/Sources/Services/NativeCall/NativeCallStyle.swift create mode 100644 ElementX/Sources/Services/NativeCall/NativeCallSystem.swift create mode 100644 UnitTests/Sources/NativeCallStackTests.swift 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: