diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a93b46adc..1ee0aa2e9 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 03D684A3AE85A23B3DA3B43F /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26747B3154A5DBC3A7E24A5 /* Image.swift */; }; 0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 196004E7695FBA292A7944AF /* ScreenTrackerViewModifier.swift */; }; 044DD8F80231BC30570F7965 /* UserDiscoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AAD845E53B0C8B5E0812C2 /* UserDiscoveryService.swift */; }; + 04778AA4D6AD2E153D7AAFF2 /* CallScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8866FE1CCCF10305FCACBC /* CallScreenUITests.swift */; }; 04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422724361B6555364C43281E /* RoomHeaderView.swift */; }; 04F17DE71A50206336749BAC /* UserPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */; }; 059173B3C77056C406906B6D /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = D4DA544B2520BFA65D6DB4BB /* target.yml */; }; @@ -28,6 +29,7 @@ 06D3942496E9E0E655F14D21 /* NotificationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */; }; 071A017E415AD378F2961B11 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227AC5D71A4CE43512062243 /* URL.swift */; }; 07756D532EFE33DD1FA258E5 /* GeoURITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7ED2EF5BDBAD2A7DBC4636 /* GeoURITests.swift */; }; + 07CC13C5729C24255348CBBD /* ElementCallWidgetDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309AD8BAE6437C31BA7157BF /* ElementCallWidgetDriver.swift */; }; 086D01E79C8E8D3F004FAF21 /* AudioPlayerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC9104846487244648D32C6D /* AudioPlayerProtocol.swift */; }; 08CB4BD12CEEDE6AAE4A18DD /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035177BCD8E8308B098AC3C2 /* WindowManager.swift */; }; 095C0ACFC234E0550A6404C5 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */; }; @@ -179,10 +181,12 @@ 352C439BE0F75E101EF11FB1 /* RoomScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */; }; 355B11D08CE0CEF97A813236 /* AppRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */; }; 35E975CFDA60E05362A7CF79 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 1222DB76B917EB8A55365BA5 /* target.yml */; }; + 366D5BFE52CB79E804C7D095 /* CallScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD9547E47C58930E2CE8306 /* CallScreenViewModelTests.swift */; }; 368C8758FCD079E6AAA18C2C /* NoticeRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */; }; 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */; }; 36AD4DD4C798E22584ED3200 /* Emojibase in Frameworks */ = {isa = PBXBuildFile; productRef = C05729B1684C331F5FFE9232 /* Emojibase */; }; 36CD6E11B37396E14F032CB6 /* WysiwygComposer in Frameworks */ = {isa = PBXBuildFile; productRef = CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */; }; + 370AF5BFCD4384DD455479B6 /* ElementCallWidgetDriverProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C11AD9813045E44F950410 /* ElementCallWidgetDriverProtocol.swift */; }; 377980ABF16525114E72DDE2 /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = 2B9ACE4FCACB5A8812154424 /* Version */; }; 37D789F24199B32E3FD1AA7B /* FileRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */; }; 383055C6ABE5BE058CEE1DDB /* WelcomeScreenScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FE5EF0AFFE360C66420AAE /* WelcomeScreenScreenCoordinator.swift */; }; @@ -193,6 +197,7 @@ 3982C505960006B341CFD0C6 /* UserDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */; }; 3982E60F9C126437D5E488A3 /* PillContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A6314FDC51DA25712D9A81 /* PillContextTests.swift */; }; 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */; }; + 39A987B3E41B976D1DF944C6 /* CallScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A63A59BFDDC494B1C20119 /* CallScreenViewModel.swift */; }; 3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201305507D7DFD16E544563A /* EmojiLoaderProtocol.swift */; }; 3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */; }; 3A5BD701D1AC916AC534F52C /* OnboardingScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB26F24164E9461B2054D0B3 /* OnboardingScreenModels.swift */; }; @@ -362,6 +367,7 @@ 6AD722DD92E465E56D2885AB /* BugReportScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA919F521E9F0EE3638AFC85 /* BugReportScreen.swift */; }; 6B31508C6334C617360C2EAB /* RoomMemberDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC589E641AE46EFB2962534D /* RoomMemberDetailsViewModelTests.swift */; }; 6B4BF4A6450F55939B49FAEF /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67779D9A1B797285A09B7720 /* PollOptionView.swift */; }; + 6BAD956B909A6E29F6CC6E7C /* ButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC23C63849452BC86EA2852 /* ButtonStyle.swift */; }; 6BB6944443C421C722ED1E7D /* portrait_test_video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */; }; 6C34237AFB808E38FC8776B9 /* RoomStateEventStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */; }; 6C5A2C454E6C198AB39ED760 /* SharedUserDefaultsKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA8DC95C079805B0B56E8A9 /* SharedUserDefaultsKeys.swift */; }; @@ -394,6 +400,7 @@ 754602A7B2AAD443C4228ED4 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 7731767AE437BA3BD2CC14A8 /* Sentry */; }; 755727E0B756430DFFEC4732 /* SessionVerificationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */; }; 762DAF94846C7AC8550F1CC1 /* MediaPlayerProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */; }; + 763D69741D58D2B650BC1FC9 /* CallScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F37FA1A5D55633E1942B153B /* CallScreenCoordinator.swift */; }; 764AFCC225B044CF5F9B41E5 /* PaginationIndicatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EEA67A6796BDC2761619C5 /* PaginationIndicatorRoomTimelineView.swift */; }; 767D366C40F1311CFA333763 /* PillContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86376BEE425704AEE197CA54 /* PillContext.swift */; }; 76BA28216FBAF83B2D86A027 /* InvitesScreenCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2A71915C1F075E403F559C /* InvitesScreenCell.swift */; }; @@ -442,6 +449,7 @@ 8317E1314C00DCCC99D30DA8 /* TextBasedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9227F7495DA43324050A863 /* TextBasedRoomTimelineItem.swift */; }; 83A4DAB181C56987C3E804FF /* MapTilerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B9F5BC4C80543DE7228B9D /* MapTilerStyle.swift */; }; 84226AD2E1F1FBC965F3B09E /* UnitTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E19C4645D3F5F9FB02355 /* UnitTestsAppCoordinator.swift */; }; + 84CAE3E96D93194DA06B9194 /* CallScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9AD6AE5FC868962F090740 /* CallScreenViewModelProtocol.swift */; }; 84EFCB95F9DA2979C8042B26 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; 8544657DEEE717ED2E22E382 /* RoomNotificationSettingsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */; }; 854E82E064BA53CD0BC45600 /* LocationRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6613DE16AD26B3A74DA1F5 /* LocationRoomTimelineItemContent.swift */; }; @@ -644,6 +652,7 @@ B717A820BE02C6FE2CB53F6E /* WaitlistScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B697816AF93DA06EC58C5D70 /* WaitlistScreenViewModelProtocol.swift */; }; B721125D17A0BA86794F29FB /* MockServerSelectionScreenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E057FB1F07A5C201C89061 /* MockServerSelectionScreenState.swift */; }; B796A25F282C0A340D1B9C12 /* ImageRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */; }; + B7C9E07F4F9CCC8DD7156A20 /* CallScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28146817C61423CACCF942F5 /* CallScreenModels.swift */; }; B828C600A54B2EE20871A451 /* PerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD700E035C85738EE4B97129 /* PerformanceTests.swift */; }; B879446FD8E65A711EF8F9F7 /* AdvancedSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63B69F9A2BC74DD40DC75C8 /* AdvancedSettingsScreenViewModel.swift */; }; B89990DD875B0B603D4D4332 /* NotificationItemProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B927CF5EF7FCCDA5EDC474B /* NotificationItemProxyProtocol.swift */; }; @@ -733,6 +742,7 @@ D0550B8E0AE2C0CDBE52C88F /* MediaPlayerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE87C931165F5E201CACBB87 /* MediaPlayerProtocol.swift */; }; D12F440F7973F1489F61389D /* NotificationSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F64447FF544298A6A3BEF85 /* NotificationSettingsScreenModels.swift */; }; D181AC8FF236B7F91C0A8C28 /* MapTiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AA3F4B285570805CB0CCDD /* MapTiler.swift */; }; + D19A748E95E2FAB2940570F0 /* CallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4103AB4340F2974D690A12A /* CallScreen.swift */; }; D1DFECA12FBF5346EAC4EE92 /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A931ECBDC32FC90A6480751F /* WaveformView.swift */; }; D1EEF0CB0F5D9C15E224E670 /* landscape_test_video.mov in Resources */ = {isa = PBXBuildFile; fileRef = 9A2AC7BE17C05CF7D2A22338 /* landscape_test_video.mov */; }; D2A15D03F81342A09340BD56 /* AnalyticsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFEEE93B82937B2E86F92EB /* AnalyticsScreen.swift */; }; @@ -1038,6 +1048,7 @@ 1CC575D1895FA62591451A93 /* RoomMemberDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreen.swift; sourceTree = ""; }; 1D56469A9EE0CFA2B7BA9760 /* SessionVerificationControllerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyProtocol.swift; sourceTree = ""; }; 1D67E616BCA82D8A1258D488 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + 1D8866FE1CCCF10305FCACBC /* CallScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenUITests.swift; sourceTree = ""; }; 1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenUITests.swift; sourceTree = ""; }; 1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelProtocol.swift; sourceTree = ""; }; 1DFE0E493FB55E5A62E7852A /* ProposedViewSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProposedViewSize.swift; sourceTree = ""; }; @@ -1072,6 +1083,7 @@ 27B8315A340B46F98B9C5AF0 /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = ""; }; 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenModels.swift; sourceTree = ""; }; 27EA0F71A3A400A202E15318 /* CreatePollScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePollScreen.swift; sourceTree = ""; }; + 28146817C61423CACCF942F5 /* CallScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenModels.swift; sourceTree = ""; }; 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCacheTests.swift; sourceTree = ""; }; 287FC98AF2664EAD79C0D902 /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; 28C19F54A0C4FC9AB7ABD583 /* TextRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineItemContent.swift; sourceTree = ""; }; @@ -1091,6 +1103,7 @@ 2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxy.swift; sourceTree = ""; }; 2FD0E68C42CA7DDCD4CAD68D /* MentionSuggestionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionSuggestionItemView.swift; sourceTree = ""; }; 303FCADE77DF1F3670C086ED /* BugReportScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModel.swift; sourceTree = ""; }; + 309AD8BAE6437C31BA7157BF /* ElementCallWidgetDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallWidgetDriver.swift; sourceTree = ""; }; 30ED584467DB380E3CEFB1DB /* NotificationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerTests.swift; sourceTree = ""; }; 314F1C79850BE46E8ABEAFCB /* ReadReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceipt.swift; sourceTree = ""; }; 31A6314FDC51DA25712D9A81 /* PillContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillContextTests.swift; sourceTree = ""; }; @@ -1112,6 +1125,7 @@ 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyMock.swift; sourceTree = ""; }; 376D941BF8BB294389C0DE24 /* MapTilerURLBuildersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerURLBuildersTests.swift; sourceTree = ""; }; 37A243E04B58DC6E41FDCD82 /* EmojiItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItem.swift; sourceTree = ""; }; + 37A63A59BFDDC494B1C20119 /* CallScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenViewModel.swift; sourceTree = ""; }; 37CA26F55123E36B50DB0B3A /* AttributedStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringTests.swift; sourceTree = ""; }; 37E727F7E0BCE8A0BBFD33FF /* OnboardingScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreenCoordinator.swift; sourceTree = ""; }; 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyMock.swift; sourceTree = ""; }; @@ -1370,6 +1384,7 @@ 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoader.swift; sourceTree = ""; }; 8BEBF0E59F25E842EDB6FD11 /* LocationSharingScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSharingScreenModels.swift; sourceTree = ""; }; 8C8616254EE40CA8BA5E9BC2 /* VideoRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItemContent.swift; sourceTree = ""; }; + 8CC23C63849452BC86EA2852 /* ButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyle.swift; sourceTree = ""; }; 8D168471461717AF5689F64B /* OnboardingScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreenUITests.swift; sourceTree = ""; }; 8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomStateEventStringBuilder.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; @@ -1446,6 +1461,7 @@ A5DDF245FA51CF75F89E58A4 /* CreatePollScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePollScreenUITests.swift; sourceTree = ""; }; A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; + A6C11AD9813045E44F950410 /* ElementCallWidgetDriverProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallWidgetDriverProtocol.swift; sourceTree = ""; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = ""; }; @@ -1468,6 +1484,7 @@ ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenUITests.swift; sourceTree = ""; }; AD378D580A41E42560C60E9C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; AD6B522BD637845AB9570B10 /* RoomNotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsProxy.swift; sourceTree = ""; }; + AD9AD6AE5FC868962F090740 /* CallScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenViewModelProtocol.swift; sourceTree = ""; }; AD9CB3B9DFA353AB2B7CD9F8 /* NotificationSettingsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenCoordinator.swift; sourceTree = ""; }; ADB35E2DB4EFE8E6F3959629 /* InviteUsersScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenUITests.swift; sourceTree = ""; }; ADD9E0FFA29EAACFF3AB9732 /* SessionVerificationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModel.swift; sourceTree = ""; }; @@ -1578,6 +1595,7 @@ CA89A2DD51B6BBE1DA55E263 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; CA90BD288E5AE6BC643AFDDF /* TemplateScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenCoordinator.swift; sourceTree = ""; }; CACA846B3E3E9A521D98B178 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + CAD9547E47C58930E2CE8306 /* CallScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenViewModelTests.swift; sourceTree = ""; }; CB26F24164E9461B2054D0B3 /* OnboardingScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreenModels.swift; sourceTree = ""; }; CB8D34E94AB07128DB73D6C7 /* PillConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillConstants.swift; sourceTree = ""; }; CBBCC6E74774E79B599625D0 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; @@ -1656,6 +1674,7 @@ E3059CFA00C67D8787273B20 /* ServerSelectionScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModel.swift; sourceTree = ""; }; E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = ""; }; E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientProtocol.swift; sourceTree = ""; }; + E4103AB4340F2974D690A12A /* CallScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreen.swift; sourceTree = ""; }; E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModel.swift; sourceTree = ""; }; E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorModalView.swift; sourceTree = ""; }; E44928D844E16EE48A311FCA /* ComposerToolbarViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbarViewModelProtocol.swift; sourceTree = ""; }; @@ -1707,6 +1726,7 @@ F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixEntityRegexTests.swift; sourceTree = ""; }; F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItem.swift; sourceTree = ""; }; F36C0A6D59717193F49EA986 /* UserSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionTests.swift; sourceTree = ""; }; + F37FA1A5D55633E1942B153B /* CallScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenCoordinator.swift; sourceTree = ""; }; F3BC2D3573D900A9C9F8C191 /* HomeScreenUserMenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenUserMenuButton.swift; sourceTree = ""; }; F3BC6BBEAF640C64C10C0340 /* CompletionSuggestionServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionServiceProtocol.swift; sourceTree = ""; }; F3EAE3E9D5EF4A6D5D9C6CFD /* EmojiPickerScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenViewModel.swift; sourceTree = ""; }; @@ -1930,6 +1950,7 @@ 0ED3F5C21537519389C07644 /* BugReport */, 8039515BAA53B7C3275AC64A /* Client */, 8C3BAE06B336D97DABBE2509 /* CreateRoom */, + 92E99C57D7F92ED16F73282C /* ElementCall */, 39557ADF21345E18F3865B9E /* Emojis */, CA555F7C7CA382ACACF0D82B /* Keychain */, 79E560F5113ED25D172E550C /* Media */, @@ -1998,6 +2019,14 @@ path = ViewModel; sourceTree = ""; }; + 109C9345160DC608877BD25C /* View */ = { + isa = PBXGroup; + children = ( + E4103AB4340F2974D690A12A /* CallScreen.swift */, + ); + path = View; + sourceTree = ""; + }; 114DC16B28140F885FD833E2 /* NotificationSettings */ = { isa = PBXGroup; children = ( @@ -2011,6 +2040,18 @@ path = NotificationSettings; sourceTree = ""; }; + 1185EECDD07495D65AC84AFC /* CallScreen */ = { + isa = PBXGroup; + children = ( + F37FA1A5D55633E1942B153B /* CallScreenCoordinator.swift */, + 28146817C61423CACCF942F5 /* CallScreenModels.swift */, + 37A63A59BFDDC494B1C20119 /* CallScreenViewModel.swift */, + AD9AD6AE5FC868962F090740 /* CallScreenViewModelProtocol.swift */, + 109C9345160DC608877BD25C /* View */, + ); + path = CallScreen; + sourceTree = ""; + }; 13ACE3300D6A86770E757FC0 /* View */ = { isa = PBXGroup; children = ( @@ -2217,6 +2258,7 @@ children = ( 8F21ED7205048668BEB44A38 /* AppActivityView.swift */, CC743C7A85E3171BCBF0A653 /* AvatarHeaderView.swift */, + 8CC23C63849452BC86EA2852 /* ButtonStyle.swift */, 0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */, B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */, C352359663A0E52BA20761EE /* LoadableImage.swift */, @@ -2867,6 +2909,7 @@ 6DFCAA239095A116976E32C4 /* BackgroundTaskTests.swift */, EFFD3200F9960D4996159F10 /* BugReportServiceTests.swift */, 7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */, + CAD9547E47C58930E2CE8306 /* CallScreenViewModelTests.swift */, D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */, CA29952595B804DA221A0C1D /* ComposerToolbarViewModelTests.swift */, 3EB1D0C69FEDD93404DF927E /* CreatePollScreenViewModelTests.swift */, @@ -3297,6 +3340,15 @@ path = View; sourceTree = ""; }; + 92E99C57D7F92ED16F73282C /* ElementCall */ = { + isa = PBXGroup; + children = ( + 309AD8BAE6437C31BA7157BF /* ElementCallWidgetDriver.swift */, + A6C11AD9813045E44F950410 /* ElementCallWidgetDriverProtocol.swift */, + ); + path = ElementCall; + sourceTree = ""; + }; 93F4D089C78719B688D576ED /* WelcomeScreenScreen */ = { isa = PBXGroup; children = ( @@ -3339,6 +3391,7 @@ 349C633291427A0F29C28C54 /* AppLockScreenUITests.swift */, 5D2D0A6F1ABC99D29462FB84 /* AuthenticationCoordinatorUITests.swift */, C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */, + 1D8866FE1CCCF10305FCACBC /* CallScreenUITests.swift */, A5DDF245FA51CF75F89E58A4 /* CreatePollScreenUITests.swift */, F8CEB4634C0DD7779C4AB504 /* CreateRoomScreenUITests.swift */, 3F684BDD23ECEADB3053BA5A /* DeveloperOptionsScreenUITests.swift */, @@ -3987,6 +4040,7 @@ 77566988A0A4F94744C3818B /* AppLockScreen */, E74CD7681375AD2EAA34D66B /* Authentication */, 53FB148CD26AFB6A5B9E20B3 /* BugReportScreen */, + 1185EECDD07495D65AC84AFC /* CallScreen */, 27F2500AC8736AAE774520C0 /* ComposerToolbar */, 90DC2E28718955ED87AD1456 /* CreatePollScreen */, C18958141C8ED6D778F779A4 /* CreateRoom */, @@ -4688,6 +4742,7 @@ 0F9E38A75337D0146652ACAB /* BackgroundTaskTests.swift in Sources */, 7F61F9ACD5EC9E845EF3EFBF /* BugReportServiceTests.swift in Sources */, C7CFDB4929DDD9A3B5BA085D /* BugReportViewModelTests.swift in Sources */, + 366D5BFE52CB79E804C7D095 /* CallScreenViewModelTests.swift in Sources */, B5321A1F5B26A0F3EC54909E /* CollapsibleFlowLayoutTests.swift in Sources */, 3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */, 0C932A5158C1D0604DFC5750 /* ComposerToolbarViewModelTests.swift in Sources */, @@ -4864,6 +4919,12 @@ 3DA57CA0D609A6B37CA1DC2F /* BugReportService.swift in Sources */, 172E6E9A612ADCF10A62CF13 /* BugReportServiceProtocol.swift in Sources */, E1DF24D085572A55C9758A2D /* Bundle.swift in Sources */, + 6BAD956B909A6E29F6CC6E7C /* ButtonStyle.swift in Sources */, + D19A748E95E2FAB2940570F0 /* CallScreen.swift in Sources */, + 763D69741D58D2B650BC1FC9 /* CallScreenCoordinator.swift in Sources */, + B7C9E07F4F9CCC8DD7156A20 /* CallScreenModels.swift in Sources */, + 39A987B3E41B976D1DF944C6 /* CallScreenViewModel.swift in Sources */, + 84CAE3E96D93194DA06B9194 /* CallScreenViewModelProtocol.swift in Sources */, BB6BF528BC7F5B87E08C4F18 /* CameraPicker.swift in Sources */, E14E469CD97550D0FC58F3CA /* CancellableTask.swift in Sources */, 6A0E7551E0D1793245F34CDD /* ClientError.swift in Sources */, @@ -4914,6 +4975,8 @@ 2955F4C160CFD7794D819C64 /* EffectsScene.swift in Sources */, AE1160076F663BF14E0E893A /* EffectsView.swift in Sources */, FE4593FC2A02AAF92E089565 /* ElementAnimations.swift in Sources */, + 07CC13C5729C24255348CBBD /* ElementCallWidgetDriver.swift in Sources */, + 370AF5BFCD4384DD455479B6 /* ElementCallWidgetDriverProtocol.swift in Sources */, D8CFF02C2730EE5BC4F17ABF /* ElementToggleStyle.swift in Sources */, 7C1A7B594B2F8143F0DD0005 /* ElementXAttributeScope.swift in Sources */, 7361B011A79BF723D8C9782B /* EmojiCategory.swift in Sources */, @@ -5399,6 +5462,7 @@ ACF094CF3BF02DBFA6DFDE60 /* AuthenticationCoordinatorUITests.swift in Sources */, 7756C4E90CABE6F14F7920A0 /* BugReportUITests.swift in Sources */, 94D0F36A87E596A93C0C178A /* Bundle.swift in Sources */, + 04778AA4D6AD2E153D7AAFF2 /* CallScreenUITests.swift in Sources */, 5E415EF9A5D31B1690CE27F5 /* CreatePollScreenUITests.swift in Sources */, 9F19096BFA629C0AC282B1E4 /* CreateRoomScreenUITests.swift in Sources */, C1F863E16BDBC87255D23B57 /* DeveloperOptionsScreenUITests.swift in Sources */, diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 9e689a92b..eef334777 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -43,6 +43,7 @@ final class AppSettings { case voiceMessageEnabled case mentionsEnabled case appLockFlowEnabled + case elementCallEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -203,6 +204,10 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.richTextEditorEnabled, defaultValue: true, storageType: .userDefaults(store)) var richTextEditorEnabled + // MARK: - Element Call + + let elementCallBaseURL: URL = "https://call.element.io" + // MARK: - Notifications @UserPreference(key: UserDefaultsKeys.enableNotifications, defaultValue: true, storageType: .userDefaults(store)) @@ -261,4 +266,7 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.appLockFlowEnabled, defaultValue: false, storageType: .volatile) var appLockFlowEnabled + + @UserPreference(key: UserDefaultsKeys.elementCallEnabled, defaultValue: false, storageType: .userDefaults(store)) + var elementCallEnabled } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 4775798ad..7bed7d3c8 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -22,6 +22,20 @@ import UserNotifications enum RoomFlowCoordinatorAction: Equatable { case presentedRoom(String) case dismissedRoom + case presentCallScreen(roomProxy: RoomProxyProtocol) + + static func == (lhs: RoomFlowCoordinatorAction, rhs: RoomFlowCoordinatorAction) -> Bool { + switch (lhs, rhs) { + case (.presentedRoom(let lhsRoomID), .presentedRoom(let rhsRoomID)): + return lhsRoomID == rhsRoomID + case (.dismissedRoom, .dismissedRoom): + return true + case (.presentCallScreen(let lhsRoomProxy), .presentCallScreen(let rhsRoomProxy)): + return lhsRoomProxy.id == rhsRoomProxy.id + default: + return false + } + } } class RoomFlowCoordinator: FlowCoordinatorProtocol { @@ -372,6 +386,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.presentRoomMemberDetails(member: .init(value: member))) case .presentMessageForwarding(let itemID): stateMachine.tryEvent(.presentMessageForwarding(itemID: itemID)) + case .presentCallScreen: + guard let roomProxy = self.roomProxy else { + fatalError() + } + + actionsSubject.send(.presentCallScreen(roomProxy: roomProxy)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index b434c0c99..1984980c0 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -85,6 +85,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { case .dismissedRoom: stateMachine.processEvent(.deselectRoom) analytics.signpost.endRoomFlow() + case .presentCallScreen(let roomProxy): + presentCallScreen(roomProxy: roomProxy) } } .store(in: &cancellables) @@ -453,4 +455,23 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { self?.stateMachine.processEvent(.closedInvitesScreen) } } + + // MARK: Calls + + private func presentCallScreen(roomProxy: RoomProxyProtocol) { + let callScreenCoordinator = CallScreenCoordinator(parameters: .init(roomProxy: roomProxy, + callBaseURL: appSettings.elementCallBaseURL, + clientID: InfoPlistReader.main.bundleIdentifier)) + + callScreenCoordinator.actions + .sink { [weak self] action in + switch action { + case .dismiss: + self?.navigationSplitCoordinator.setSheetCoordinator(nil) + } + } + .store(in: &cancellables) + + navigationSplitCoordinator.setSheetCoordinator(callScreenCoordinator, animated: true) + } } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index ea9c6eb1b..0d5483ad7 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -425,6 +425,61 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol { setSuggestionTriggerClosure?(suggestionTrigger) } } +class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol { + var messagePublisher: PassthroughSubject { + get { return underlyingMessagePublisher } + set(value) { underlyingMessagePublisher = value } + } + var underlyingMessagePublisher: PassthroughSubject! + var actions: AnyPublisher { + get { return underlyingActions } + set(value) { underlyingActions = value } + } + var underlyingActions: AnyPublisher! + + //MARK: - start + + var startBaseURLClientIDCallsCount = 0 + var startBaseURLClientIDCalled: Bool { + return startBaseURLClientIDCallsCount > 0 + } + var startBaseURLClientIDReceivedArguments: (baseURL: URL, clientID: String)? + var startBaseURLClientIDReceivedInvocations: [(baseURL: URL, clientID: String)] = [] + var startBaseURLClientIDReturnValue: Result! + var startBaseURLClientIDClosure: ((URL, String) async -> Result)? + + func start(baseURL: URL, clientID: String) async -> Result { + startBaseURLClientIDCallsCount += 1 + startBaseURLClientIDReceivedArguments = (baseURL: baseURL, clientID: clientID) + startBaseURLClientIDReceivedInvocations.append((baseURL: baseURL, clientID: clientID)) + if let startBaseURLClientIDClosure = startBaseURLClientIDClosure { + return await startBaseURLClientIDClosure(baseURL, clientID) + } else { + return startBaseURLClientIDReturnValue + } + } + //MARK: - sendMessage + + var sendMessageCallsCount = 0 + var sendMessageCalled: Bool { + return sendMessageCallsCount > 0 + } + var sendMessageReceivedMessage: String? + var sendMessageReceivedInvocations: [String] = [] + var sendMessageReturnValue: Result! + var sendMessageClosure: ((String) async -> Result)? + + func sendMessage(_ message: String) async -> Result { + sendMessageCallsCount += 1 + sendMessageReceivedMessage = message + sendMessageReceivedInvocations.append(message) + if let sendMessageClosure = sendMessageClosure { + return await sendMessageClosure(message) + } else { + return sendMessageReturnValue + } + } +} class KeychainControllerMock: KeychainControllerProtocol { //MARK: - setRestorationToken @@ -1173,6 +1228,11 @@ class RoomProxyMock: RoomProxyProtocol { set(value) { underlyingIsTombstoned = value } } var underlyingIsTombstoned: Bool! + var isCallOngoing: Bool { + get { return underlyingIsCallOngoing } + set(value) { underlyingIsCallOngoing = value } + } + var underlyingIsCallOngoing: Bool! var canonicalAlias: String? var alternativeAliases: [String] = [] var hasUnreadNotifications: Bool { @@ -1944,6 +2004,23 @@ class RoomProxyMock: RoomProxyProtocol { return endPollPollStartIDTextReturnValue } } + //MARK: - elementCallWidgetDriver + + var elementCallWidgetDriverCallsCount = 0 + var elementCallWidgetDriverCalled: Bool { + return elementCallWidgetDriverCallsCount > 0 + } + var elementCallWidgetDriverReturnValue: ElementCallWidgetDriverProtocol! + var elementCallWidgetDriverClosure: (() -> ElementCallWidgetDriverProtocol)? + + func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol { + elementCallWidgetDriverCallsCount += 1 + if let elementCallWidgetDriverClosure = elementCallWidgetDriverClosure { + return elementCallWidgetDriverClosure() + } else { + return elementCallWidgetDriverReturnValue + } + } } class RoomTimelineProviderMock: RoomTimelineProviderProtocol { var updatePublisher: AnyPublisher { diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index af8e24378..e0e56747f 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -28,6 +28,7 @@ struct RoomProxyMockConfiguration { var isPublic = Bool.random() var isEncrypted = Bool.random() var isTombstoned = Bool.random() + var isCallOngoing = Bool.random() var canonicalAlias: String? var alternativeAliases: [String] = [] var hasUnreadNotifications = Bool.random() @@ -56,6 +57,7 @@ extension RoomProxyMock { isPublic = configuration.isPublic isEncrypted = configuration.isEncrypted isTombstoned = configuration.isTombstoned + isCallOngoing = configuration.isCallOngoing canonicalAlias = configuration.canonicalAlias alternativeAliases = configuration.alternativeAliases hasUnreadNotifications = configuration.hasUnreadNotifications diff --git a/ElementX/Sources/Other/SwiftUI/Views/ButtonStyle.swift b/ElementX/Sources/Other/SwiftUI/Views/ButtonStyle.swift new file mode 100644 index 000000000..3bc36ff9c --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/ButtonStyle.swift @@ -0,0 +1,28 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct ElementCallButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 16.0) + .padding(.vertical, 4.0) + .foregroundColor(.compound.bgCanvasDefault) + .background(Color.compound.iconAccentTertiary) + .clipShape(Capsule()) + } +} diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift b/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift new file mode 100644 index 000000000..ca24f7caa --- /dev/null +++ b/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift @@ -0,0 +1,65 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +struct CallScreenCoordinatorParameters { + let roomProxy: RoomProxyProtocol + /// Which Element Call instance should be used + let callBaseURL: URL + /// A way to identify the current client against Element Call + let clientID: String +} + +enum CallScreenCoordinatorAction { + case dismiss +} + +final class CallScreenCoordinator: CoordinatorProtocol { + private let parameters: CallScreenCoordinatorParameters + private var viewModel: CallScreenViewModelProtocol + private let actionsSubject: PassthroughSubject = .init() + + private var cancellables: Set = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: CallScreenCoordinatorParameters) { + self.parameters = parameters + + viewModel = CallScreenViewModel(roomProxy: parameters.roomProxy, + callBaseURL: parameters.callBaseURL, + clientID: parameters.clientID) + } + + func start() { + viewModel.actions.sink { [weak self] action in + guard let self else { return } + + switch action { + case .dismiss: + actionsSubject.send(.dismiss) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(CallScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift b/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift new file mode 100644 index 000000000..eab7da36e --- /dev/null +++ b/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift @@ -0,0 +1,39 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum CallScreenViewModelAction { + case dismiss +} + +struct CallScreenViewState: BindableState { + let messageHandler: String + let script: String? + var url: URL? + var bindings = Bindings() +} + +struct Bindings { + var javaScriptMessageHandler: ((Any) -> Void)? + var javaScriptEvaluator: ((String) async throws -> Any)? + + var alertInfo: AlertInfo? +} + +enum CallScreenViewAction { + case urlChanged(URL?) +} diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift new file mode 100644 index 000000000..f30b2125d --- /dev/null +++ b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift @@ -0,0 +1,206 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AVFoundation +import CallKit +import Combine +import SwiftUI + +typealias CallScreenViewModelType = StateStoreViewModel + +class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol { + private let roomProxy: RoomProxyProtocol + private let callBaseURL: URL + private let clientID: String + + private let widgetDriver: ElementCallWidgetDriverProtocol + + private let callController = CXCallController() + private let callProvider = CXProvider(configuration: .init()) + private let callID = UUID() + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + deinit { + tearDownVoIPSession(callID: callID) + } + + /// Designated initialiser + /// - Parameters: + /// - roomProxy: The room in which the call should be created + /// - callBaseURL: Which Element Call instance should be used + /// - clientID: Something to identify the current client on the Element Call side + init(roomProxy: RoomProxyProtocol, callBaseURL: URL, clientID: String) { + self.roomProxy = roomProxy + self.callBaseURL = callBaseURL + self.clientID = clientID + + widgetDriver = roomProxy.elementCallWidgetDriver() + + super.init(initialViewState: CallScreenViewState(messageHandler: Self.eventHandlerName, + script: Self.eventHandlerInjectionScript)) + + state.bindings.javaScriptMessageHandler = { [weak self] message in + guard let self, + let message = message as? String else { + return + } + + Task { + await self.widgetDriver.sendMessage(message) + } + } + + widgetDriver.messagePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] receivedMessage in + guard let self else { return } + + Task { + do { + let message = "postMessage(\(receivedMessage), '*')" + let result = try await self.state.bindings.javaScriptEvaluator?(message) + MXLog.debug("Evaluated javascript: \(message) with result: \(String(describing: result))") + } catch { + MXLog.error("Received javascript evaluation error: \(error)") + } + } + } + .store(in: &cancellables) + + widgetDriver.actions + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + guard let self else { return } + + switch action { + case .callEnded: + actionsSubject.send(.dismiss) + default: + break + } + } + .store(in: &cancellables) + + Task { + switch await widgetDriver.start(baseURL: callBaseURL, clientID: clientID) { + case .success(let url): + state.url = url + case .failure(let error): + MXLog.error("Failed starting ElementCall Widget Driver with error: \(error)") + state.bindings.alertInfo = .init(id: UUID(), title: L10n.errorUnknown, primaryButton: .init(title: L10n.actionOk, action: { [weak self] in + self?.actionsSubject.send(.dismiss) + })) + + return + } + + do { + try await setupVoIPSession(callID: callID) + } catch { + MXLog.error("Failed setting up VoIP session with error: \(error)") + } + } + } + + override func process(viewAction: CallScreenViewAction) { + switch viewAction { + case .urlChanged(let url): + guard let url else { return } + MXLog.info("URL changed to: \(url)") + } + } + + // MARK: - CXCallObserverDelegate + + func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) { + MXLog.info("Call changed: \(call)") + } + + // MARK: - CXProviderDelegate + + func providerDidReset(_ provider: CXProvider) { + MXLog.info("Call provider did reset: \(provider)") + } + + // MARK: - Private + + private static let eventHandlerName = "elementx" + + private static var eventHandlerInjectionScript: String { + """ + window.addEventListener( + "message", + (event) => { + let message = {data: event.data, origin: event.origin} + if (message.data.response && message.data.api == "toWidget" + || !message.data.response && message.data.api == "fromWidget") { + window.webkit.messageHandlers.\(eventHandlerName).postMessage(JSON.stringify(message.data)); + }else{ + console.log("-- skipped event handling by the client because it is send from the client itself."); + } + }, + false, + ); + """ + } + + private func evaluateJavaScript(_ script: String) async -> String? { + guard let evaluator = state.bindings.javaScriptEvaluator else { + fatalError("Invalid javaScriptEvaluator") + } + + do { + return try await evaluator(script) as? String + } catch { + MXLog.error("Failed evaluating javaScript with error: \(error)") + return nil + } + } + + private func setupVoIPSession(callID: UUID) async throws { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .videoChat, options: []) + try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation) + + let handle = CXHandle(type: .generic, value: roomProxy.roomTitle) + let startCallAction = CXStartCallAction(call: callID, handle: handle) + startCallAction.isVideo = true + + let transaction = CXTransaction(action: startCallAction) + + try await callController.request(transaction) + } + + private nonisolated func tearDownVoIPSession(callID: UUID?) { + guard let callID else { + return + } + + try? AVAudioSession.sharedInstance().setActive(false) + + let endCallAction = CXEndCallAction(call: callID) + let transaction = CXTransaction(action: endCallAction) + + callController.request(transaction) { error in + if let error { + MXLog.error("Failed transaction with error: \(error)") + } + } + } +} diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenViewModelProtocol.swift b/ElementX/Sources/Screens/CallScreen/CallScreenViewModelProtocol.swift new file mode 100644 index 000000000..8a3670f6d --- /dev/null +++ b/ElementX/Sources/Screens/CallScreen/CallScreenViewModelProtocol.swift @@ -0,0 +1,23 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine + +@MainActor +protocol CallScreenViewModelProtocol { + var actions: AnyPublisher { get } + var context: CallScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift new file mode 100644 index 000000000..a43177fb5 --- /dev/null +++ b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift @@ -0,0 +1,181 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI +import WebKit + +struct CallScreen: View { + @ObservedObject var context: CallScreenViewModel.Context + + var body: some View { + WebView(url: context.viewState.url, viewModelContext: context) + .navigationTitle("Call") + .navigationBarTitleDisplayMode(.inline) + .ignoresSafeArea(edges: .bottom) + .presentationDragIndicator(.visible) + .environment(\.colorScheme, .dark) + .alert(item: $context.alertInfo) + } +} + +private struct WebView: UIViewRepresentable { + let url: URL? + let viewModelContext: CallScreenViewModel.Context + + func makeUIView(context: Context) -> WKWebView { + context.coordinator.webView + } + + func makeCoordinator() -> Coordinator { + Coordinator(viewModelContext: viewModelContext) + } + + func updateUIView(_ webView: WKWebView, context: Context) { + if let url { + context.coordinator.url = url + let request = URLRequest(url: url) + webView.load(request) + } + } + + @MainActor + class Coordinator: NSObject, WKScriptMessageHandler, WKUIDelegate, WKNavigationDelegate { + private let viewModelContext: CallScreenViewModel.Context + private var webViewURLObservation: NSKeyValueObservation? + + private(set) var webView: WKWebView! + + var url: URL! + + init(viewModelContext: CallScreenViewModel.Context) { + self.viewModelContext = viewModelContext + + super.init() + + DispatchQueue.main.async { + // Avoid `Publishing changes from within view update warnings` + viewModelContext.javaScriptEvaluator = self.evaluateJavaScript(_:) + } + + let configuration = WKWebViewConfiguration() + + let userContentController = WKUserContentController() + userContentController.add(self, name: viewModelContext.viewState.messageHandler) + + configuration.userContentController = userContentController + configuration.allowsInlineMediaPlayback = true + configuration.allowsPictureInPictureMediaPlayback = true + + if let script = viewModelContext.viewState.script { + let userScript = WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: false) + configuration.userContentController.addUserScript(userScript) + } + + webView = WKWebView(frame: .zero, configuration: configuration) + webView.uiDelegate = self + webView.navigationDelegate = self + + // Try matching Element Call colors + webView.isOpaque = false + webView.backgroundColor = .compound.bgCanvasDefault + webView.scrollView.backgroundColor = .compound.bgCanvasDefault + } + + func evaluateJavaScript(_ script: String) async throws -> Any? { + // After testing different scenarios it seems that when using async/await version of these + // methods wkwebView expects JavaScript to return with a value (something other than Void), + // if there is no value returning from the JavaScript that you evaluate you will have a crash. + try await withCheckedThrowingContinuation { continuaton in + webView.evaluateJavaScript(script) { result, error in + if let error { + continuaton.resume(throwing: error) + } else { + continuaton.resume(returning: result) + } + } + } + } + + // MARK: - WKScriptMessageHandler + + nonisolated func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + Task { @MainActor in + viewModelContext.javaScriptMessageHandler?(message.body) + } + } + + // MARK: - WKUIDelegate + + func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision { + // Don't allow permissions for domains different than what the call was started on + guard origin.host == url.host else { + return .deny + } + + return .grant + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + // Allow any content from the main URL. + if navigationAction.request.url?.host == url.host { + return .allow + } + + // Additionally allow any embedded content such as captchas. + if let targetFrame = navigationAction.targetFrame, !targetFrame.isMainFrame { + return .allow + } + + // Otherwise the request is invalid. + return .cancel + } + + nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor in + viewModelContext.send(viewAction: .urlChanged(webView.url)) + } + } + } +} + +// MARK: - Previews + +struct CallScreen_Previews: PreviewProvider { + static let viewModel = { + let roomProxy = RoomProxyMock() + + let widgetDriver = ElementCallWidgetDriverMock() + widgetDriver.underlyingMessagePublisher = .init() + widgetDriver.underlyingActions = PassthroughSubject().eraseToAnyPublisher() + widgetDriver.startBaseURLClientIDReturnValue = .success(URL.userDirectory) + + roomProxy.elementCallWidgetDriverReturnValue = widgetDriver + + return CallScreenViewModel(roomProxy: roomProxy, + callBaseURL: "https://call.element.io", + clientID: "io.element.elementx") + }() + + static var previews: some View { + NavigationStack { + CallScreen(context: viewModel.context) + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index ecb81ab2b..f924aefdb 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -39,6 +39,7 @@ enum RoomScreenCoordinatorAction { case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) case presentRoomMemberDetails(member: RoomMemberProxyProtocol) case presentMessageForwarding(itemID: TimelineItemIdentifier) + case presentCallScreen } final class RoomScreenCoordinator: CoordinatorProtocol { @@ -109,6 +110,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description)) case .composer(let action): composerViewModel.process(roomAction: action) + case .displayCallScreen: + actionsSubject.send(.presentCallScreen) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 592493980..14f8b45af 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -34,6 +34,7 @@ enum RoomScreenViewModelAction { case displayMessageForwarding(itemID: TimelineItemIdentifier) case displayLocation(body: String, geoURI: GeoURI, description: String?) case composer(action: RoomScreenComposerAction) + case displayCallScreen } enum RoomScreenComposerMode: Equatable { @@ -63,7 +64,7 @@ enum RoomScreenViewAction { case paginateBackwards case selectedPollOption(pollStartID: String, optionID: String) case endPoll(pollStartID: String) - + case timelineItemMenu(itemID: TimelineItemIdentifier) case timelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction) @@ -73,7 +74,7 @@ enum RoomScreenViewAction { case tappedOnUser(userID: String) case reactionSummary(itemID: TimelineItemIdentifier, key: String) - + case retrySend(itemID: TimelineItemIdentifier) case cancelSend(itemID: TimelineItemIdentifier) @@ -81,8 +82,11 @@ enum RoomScreenViewAction { case enableLongPress(itemID: TimelineItemIdentifier) case disableLongPress(itemID: TimelineItemIdentifier) + case playPauseAudio(itemID: TimelineItemIdentifier) case seekAudio(itemID: TimelineItemIdentifier, progress: Double) + + case presentCall } enum RoomScreenComposerAction { @@ -103,9 +107,13 @@ struct RoomScreenViewState: BindableState { var isEncryptedOneToOneRoom = false var timelineViewState = TimelineViewState() // check the doc before changing this var swiftUITimelineEnabled = false + var longPressDisabledItemID: TimelineItemIdentifier? var ownUserID: String - + + var showCallButton = false + var isCallOngoing = false + var bindings: RoomScreenViewStateBindings /// A closure providing the actions to show when long pressing on an item in the timeline. @@ -127,14 +135,14 @@ struct RoomScreenViewStateBindings { /// Information describing the currently displayed alert. var alertInfo: AlertInfo? - + /// An alert info for confirmation actions (e.g. ending a poll) var confirmationAlertInfo: AlertInfo? - + var debugInfo: TimelineItemDebugInfo? var actionMenuInfo: TimelineItemActionMenuInfo? - + var sendFailedConfirmationDialogInfo: SendFailedConfirmationDialogInfo? var reactionSummaryInfo: ReactionSummaryInfo? @@ -144,7 +152,7 @@ struct TimelineItemActionMenuInfo: Equatable, Identifiable { static func == (lhs: TimelineItemActionMenuInfo, rhs: TimelineItemActionMenuInfo) -> Bool { lhs.id == rhs.id } - + let item: EventBasedTimelineItemProtocol var id: TimelineItemIdentifier { @@ -154,7 +162,7 @@ struct TimelineItemActionMenuInfo: Equatable, Identifiable { struct SendFailedConfirmationDialogInfo: ConfirmationDialogProtocol { let title = L10n.screenRoomRetrySendMenuTitle - + let itemID: TimelineItemIdentifier } @@ -184,19 +192,19 @@ struct RoomMemberState { struct TimelineViewState { var canBackPaginate = true var isBackPaginating = false - + // These can be removed when we have full swiftUI and moved as @State values in the view var scrollToBottomPublisher = PassthroughSubject() - + var itemsDictionary = OrderedDictionary() var renderedTimelineIDs = [String]() var pendingTimelineIDs = [String]() - + var timelineIDs: [String] { itemsDictionary.keys.elements } - + var itemViewStates: [RoomTimelineItemViewState] { renderedTimelineIDs.compactMap { itemsDictionary[$0] } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index a493e35bf..28be08695 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -63,6 +63,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol readReceiptsEnabled: appSettings.readReceiptsEnabled, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, ownUserID: roomProxy.ownUserID, + isCallOngoing: roomProxy.isCallOngoing, bindings: .init(reactionsCollapsed: [:])), imageProvider: mediaProvider) @@ -163,6 +164,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol message: L10n.commonPollEndConfirmation, primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), secondaryButton: .init(title: L10n.actionOk, action: { self.endPoll(pollStartID: pollStartID) })) + case .presentCall: + actionsSubject.send(.displayCallScreen) } } @@ -243,6 +246,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol .weakAssign(to: \.state.readReceiptsEnabled, on: self) .store(in: &cancellables) + appSettings.$elementCallEnabled + .weakAssign(to: \.state.showCallButton, on: self) + .store(in: &cancellables) + roomProxy.members .map { members in members.reduce(into: [String: RoomMemberState]()) { dictionary, member in diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 10ed0c2fe..b9df8cede 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Compound import SwiftUI import WysiwygComposer @@ -155,8 +156,33 @@ struct RoomScreen: View { ToolbarItem(placement: .principal) { RoomHeaderView(context: context) } + + ToolbarItem(placement: .primaryAction) { + callButton + } } - + + @ViewBuilder + private var callButton: some View { + if context.viewState.showCallButton { + if context.viewState.isCallOngoing { + Button { + context.send(viewAction: .presentCall) + } label: { + Label(L10n.actionJoin, icon: \.videoCall) + .labelStyle(.titleAndIcon) + } + .buttonStyle(ElementCallButtonStyle()) + } else { + Button { + context.send(viewAction: .presentCall) + } label: { + CompoundIcon(\.videoCall) + } + } + } + } + private var isNavigationBarHidden: Bool { composerToolbarContext.composerActionsEnabled && composerToolbarContext.composerExpanded && UIDevice.current.userInterfaceIdiom == .pad } @@ -167,11 +193,11 @@ struct RoomScreen: View { struct RoomScreen_Previews: PreviewProvider, TestablePreview { static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), - roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")), + roomProxy: RoomProxyMock(with: .init(displayName: "Preview room", isCallOngoing: true)), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) - + static var previews: some View { NavigationStack { RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock()) diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 610bf68d1..5b7f4fbe0 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -52,6 +52,7 @@ protocol DeveloperOptionsProtocol: AnyObject { var voiceMessageEnabled: Bool { get set } var mentionsEnabled: Bool { get set } var appLockFlowEnabled: Bool { get set } + var elementCallEnabled: 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 ea149d3f5..be5408d3a 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -50,6 +50,10 @@ struct DeveloperOptionsScreen: View { Text("Show user mentions") Text("Requires app reboot") } + + Toggle(isOn: $context.elementCallEnabled) { + Text("Elemement Call") + } } Section("Room creation") { diff --git a/ElementX/Sources/Services/ElementCall/ElementCallWidgetDriver.swift b/ElementX/Sources/Services/ElementCall/ElementCallWidgetDriver.swift new file mode 100644 index 000000000..98ca7b636 --- /dev/null +++ b/ElementX/Sources/Services/ElementCall/ElementCallWidgetDriver.swift @@ -0,0 +1,157 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import MatrixRustSDK + +private struct ElementCallWidgetMessage: Codable { + enum Direction: String, Codable { + case fromWidget + case toWidget + } + + enum Action: String, Codable { + case hangup = "im.vector.hangup" + } + + let direction: Direction + let action: Action + + enum CodingKeys: String, CodingKey { + case direction = "api" + case action + } +} + +class ElementCallWidgetDriver: WidgetPermissionsProvider, ElementCallWidgetDriverProtocol { + private let room: RoomProtocol + private var widgetDriver: WidgetDriverAndHandle? + + let messagePublisher = PassthroughSubject() + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(room: RoomProtocol) { + self.room = room + } + + func start(baseURL: URL, clientID: String) async -> Result { + guard let room = room as? Room else { + return .failure(.roomInvalid) + } + + guard let widgetSettings = try? newVirtualElementCallWidget(props: .init(elementCallUrl: baseURL.absoluteString, + widgetId: UUID().uuidString, + parentUrl: nil, + hideHeader: nil, + preload: nil, + fontScale: nil, + appPrompt: false, + skipLobby: true, + confineToRoom: true, + fonts: nil, + analyticsId: nil)) else { + return .failure(.failedBuildingWidgetSettings) + } + + guard let urlString = try? await generateWebviewUrl(widgetSettings: widgetSettings, room: room, + props: .init(clientId: clientID, + languageTag: nil, + theme: nil)) else { + return .failure(.failedBuildingCallURL) + } + + guard let url = URL(string: urlString) else { + return .failure(.failedParsingCallURL) + } + + guard let widgetDriver = try? makeWidgetDriver(settings: widgetSettings) else { + return .failure(.failedBuildingWidgetDriver) + } + + self.widgetDriver = widgetDriver + + Task.detached { [weak self, widgetDriver, messagePublisher] in + MXLog.debug("Started message receiving loop") + + defer { + MXLog.debug("Stopped message receiving loop") + } + + while true { + guard let receivedMessage = await widgetDriver.handle.recv() else { + return + } + + messagePublisher.send(receivedMessage) + MXLog.debug("Received message: \(receivedMessage)") + + self?.handleMessageIfNeeded(receivedMessage) + } + } + + Task.detached { [widgetDriver] in + MXLog.debug("Started widget driver") + + defer { + MXLog.debug("Stopped widget driver") + } + + await widgetDriver.driver.run(room: room, permissionsProvider: self) + } + + return .success(url) + } + + func sendMessage(_ message: String) async -> Result { + guard let widgetDriver else { + return .failure(.driverNotSetup) + } + + let result = await widgetDriver.handle.send(msg: message) + MXLog.debug("Sent message: \(message) with result: \(result)") + + handleMessageIfNeeded(message) + + return .success(result) + } + + // MARK: - WidgetPermissionsProvider + + func acquirePermissions(permissions: MatrixRustSDK.WidgetPermissions) -> MatrixRustSDK.WidgetPermissions { + permissions + } + + // MARK: - Private + + func handleMessageIfNeeded(_ message: String) { + guard let data = message.data(using: .utf8), + let widgetMessage = try? JSONDecoder().decode(ElementCallWidgetMessage.self, from: data) else { + return + } + + if widgetMessage.direction == .fromWidget { + switch widgetMessage.action { + case .hangup: + actionsSubject.send(.callEnded) + } + } + } +} diff --git a/ElementX/Sources/Services/ElementCall/ElementCallWidgetDriverProtocol.swift b/ElementX/Sources/Services/ElementCall/ElementCallWidgetDriverProtocol.swift new file mode 100644 index 000000000..e44eaf56b --- /dev/null +++ b/ElementX/Sources/Services/ElementCall/ElementCallWidgetDriverProtocol.swift @@ -0,0 +1,42 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +enum ElementCallWidgetDriverError: Error { + case roomInvalid + case failedBuildingCallURL + case failedBuildingWidgetSettings + case failedBuildingWidgetDriver + case failedParsingCallURL + case driverNotSetup +} + +enum ElementCallWidgetDriverAction { + case callEnded + case errorReceived(ElementCallWidgetDriverError) +} + +// sourcery: AutoMockable +protocol ElementCallWidgetDriverProtocol { + var messagePublisher: PassthroughSubject { get } + var actions: AnyPublisher { get } + + func start(baseURL: URL, clientID: String) async -> Result + + func sendMessage(_ message: String) async -> Result +} diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 13492c76e..936249634 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -151,6 +151,10 @@ class RoomProxy: RoomProxyProtocol { room.isTombstoned() } + var isCallOngoing: Bool { + false + } + var canonicalAlias: String? { room.canonicalAlias() } @@ -252,7 +256,7 @@ class RoomProxy: RoomProxyProtocol { return .success(()) } } - + func sendMessage(_ message: String, html: String?, inReplyTo eventID: String? = nil) async -> Result { sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) defer { @@ -430,13 +434,13 @@ class RoomProxy: RoomProxyProtocol { self.room.cancelSend(txnId: transactionID) } } - + func editMessage(_ message: String, html: String?, original eventID: String) async -> Result { sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) defer { sendMessageBackgroundTask?.stop() } - + let messageContent = buildMessageContentFor(message, html: html) return await Task.dispatch(on: messageSendingDispatchQueue) { @@ -705,6 +709,12 @@ class RoomProxy: RoomProxyProtocol { } } } + + // MARK: - Element Call + + func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol { + ElementCallWidgetDriver(room: room) + } // MARK: - Private diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 0144f5831..42bf48e94 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -55,6 +55,7 @@ protocol RoomProxyProtocol { var isSpace: Bool { get } var isEncrypted: Bool { get } var isTombstoned: Bool { get } + var isCallOngoing: Bool { get } var canonicalAlias: String? { get } var alternativeAliases: [String] { get } var hasUnreadNotifications: Bool { get } @@ -176,6 +177,10 @@ protocol RoomProxyProtocol { func sendPollResponse(pollStartID: String, answers: [String]) async -> Result func endPoll(pollStartID: String, text: String) async -> Result + + // MARK: - Element Call + + func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol } extension RoomProxyProtocol { diff --git a/UITests/Sources/CallScreenUITests.swift b/UITests/Sources/CallScreenUITests.swift new file mode 100644 index 000000000..f5d556612 --- /dev/null +++ b/UITests/Sources/CallScreenUITests.swift @@ -0,0 +1,21 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import ElementX +import XCTest + +@MainActor +class CallScreenUITests: XCTestCase { } diff --git a/UnitTests/Sources/CallScreenViewModelTests.swift b/UnitTests/Sources/CallScreenViewModelTests.swift new file mode 100644 index 000000000..c7a99ffb5 --- /dev/null +++ b/UnitTests/Sources/CallScreenViewModelTests.swift @@ -0,0 +1,22 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import ElementX + +@MainActor +class CallScreenViewModelTests: XCTestCase { } diff --git a/changelog.d/pr-1906.feature b/changelog.d/pr-1906.feature new file mode 100644 index 000000000..b77a65eaf --- /dev/null +++ b/changelog.d/pr-1906.feature @@ -0,0 +1 @@ +Add support for running Element Calls through Rust side widgets \ No newline at end of file