From e5bfc3a26dae5d181ef2c222170f9d3d7b9b01f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl?= Date: Tue, 22 Sep 2026 16:56:20 +0200 Subject: [PATCH] Add HealthChat API and extended profile services (#9) Expose OpenID tokens and homeserver credentials from the client proxy, and add fork-only services for the HealthChat member directory backend (api.healthchat.ch) plus a service that reads MSC4133 extended profile fields directly from the homeserver. Groundwork for the members tab and the profile screens. Claude-Session: https://claude.ai/code/session_013qdTbAWsZj2quQzEkagXaS --- ElementX.xcodeproj/project.pbxproj | 76 ++++ .../HealthChat/HealthChatSettings.swift | 14 + .../Mocks/Generated/GeneratedMocks.swift | 334 ++++++++++++++++++ .../Sources/Services/Client/ClientProxy.swift | 22 ++ .../Services/Client/ClientProxyProtocol.swift | 8 + .../Client/HomeserverCredentials.swift | 21 ++ .../Sources/Services/Client/OpenIDToken.swift | 36 ++ .../Services/HealthChat/ExtendedProfile.swift | 92 +++++ .../HealthChat/ExtendedProfileService.swift | 55 +++ .../ExtendedProfileServiceProtocol.swift | 20 ++ .../HealthChat/HealthChatAPIModels.swift | 134 +++++++ .../HealthChat/HealthChatAPIService.swift | 140 ++++++++ .../HealthChatAPIServiceProtocol.swift | 29 ++ .../HealthChatOpenIDTokenProvider.swift | 50 +++ .../ExtendedProfileServiceTests.swift | 117 ++++++ .../HealthChat/ExtendedProfileTests.swift | 149 ++++++++ .../HealthChat/HealthChatAPIModelsTests.swift | 173 +++++++++ .../HealthChatAPIServiceTests.swift | 251 +++++++++++++ .../HealthChatOpenIDTokenProviderTests.swift | 86 +++++ 19 files changed, 1807 insertions(+) create mode 100644 ElementX/Sources/AppHooks/HealthChat/HealthChatSettings.swift create mode 100644 ElementX/Sources/Services/Client/HomeserverCredentials.swift create mode 100644 ElementX/Sources/Services/Client/OpenIDToken.swift create mode 100644 ElementX/Sources/Services/HealthChat/ExtendedProfile.swift create mode 100644 ElementX/Sources/Services/HealthChat/ExtendedProfileService.swift create mode 100644 ElementX/Sources/Services/HealthChat/ExtendedProfileServiceProtocol.swift create mode 100644 ElementX/Sources/Services/HealthChat/HealthChatAPIModels.swift create mode 100644 ElementX/Sources/Services/HealthChat/HealthChatAPIService.swift create mode 100644 ElementX/Sources/Services/HealthChat/HealthChatAPIServiceProtocol.swift create mode 100644 ElementX/Sources/Services/HealthChat/HealthChatOpenIDTokenProvider.swift create mode 100644 UnitTests/Sources/HealthChat/ExtendedProfileServiceTests.swift create mode 100644 UnitTests/Sources/HealthChat/ExtendedProfileTests.swift create mode 100644 UnitTests/Sources/HealthChat/HealthChatAPIModelsTests.swift create mode 100644 UnitTests/Sources/HealthChat/HealthChatAPIServiceTests.swift create mode 100644 UnitTests/Sources/HealthChat/HealthChatOpenIDTokenProviderTests.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index cf4cfcecd..90b550022 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -204,6 +204,7 @@ 1D69E31913DF66426985909B /* EmojiPickerScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11151E78D6BB2B04A8FBD389 /* EmojiPickerScreenViewModelProtocol.swift */; }; 1DC227816777A2F3A19657E5 /* RoomDirectorySearchScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF71646898A2F720C5BFDF5 /* RoomDirectorySearchScreenViewModel.swift */; }; 1E414736CCF094E2CBD8E482 /* NotificationToneManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2A2B168C8DC14A1A9D1C64 /* NotificationToneManagerProtocol.swift */; }; + 1E822B072359DD37EEA8CE78 /* HealthChatSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715B60D68D20808E328DA1DA /* HealthChatSettings.swift */; }; 1ECE584D2CFA0FEF0ADE458C /* TestablePreviewsDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA723686F23EF45E2B398FBC /* TestablePreviewsDictionary.swift */; }; 1F1BCEE81056FD9F344F3B0E /* ClassicAppAccountManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80935ADC7ED867226225F965 /* ClassicAppAccountManagerTests.swift */; }; 1F3232BD368DF430AB433907 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = 07FEEEDB11543A7DED420F04 /* Compound */; }; @@ -402,6 +403,7 @@ 407DCE030E0F9B7C9861D38A /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = 81DB3AB6CE996AB3954F4F03 /* KZFileWatchers */; }; 40839665086F5DAFA55D0EED /* AnalyticsConsentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353024006CB726E9F9187B3A /* AnalyticsConsentState.swift */; }; 414F50CFCFEEE2611127DCFB /* RestorationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3558A15CFB934F9229301527 /* RestorationToken.swift */; }; + 41B72420FE80DCD1FD115D47 /* ExtendedProfileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A1F965895E27CAEA93B58A /* ExtendedProfileService.swift */; }; 41B84DF733BE6CB0180D1CC1 /* NotificationContentBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F0AF1A13D358CA0BDA6065F /* NotificationContentBuilderTests.swift */; }; 41C5DA0C06F30311A221E85B /* ClientSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */; }; 41CE5E1289C8768FC5B6490C /* RoomTimelineItemViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */; }; @@ -463,6 +465,7 @@ 4AAA8606FBA290E23D15422E /* AvatarHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC743C7A85E3171BCBF0A653 /* AvatarHeaderView.swift */; }; 4AC0B3634BDA6817EC7DF539 /* ClientFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 996AF8CF3FCF19B62F005C57 /* ClientFactory.swift */; }; 4B25CDB4AA2C2AC0B4577217 /* EditRoomAddressListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2776E63E02719B20758EB78 /* EditRoomAddressListRow.swift */; }; + 4B5E2E8FBE54B73F79E607BC /* HealthChatOpenIDTokenProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D94248E57F753281A220BBCC /* HealthChatOpenIDTokenProvider.swift */; }; 4B978C09567387EF4366BD7A /* MediaLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF1AC723C2609C7705569CA /* MediaLoaderTests.swift */; }; 4BAB8222DBA0B4207D1223E0 /* NotificationSettingsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */; }; 4BB282209EA82015D0DF8F89 /* NavigationStackCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */; }; @@ -494,6 +497,7 @@ 4EAC427267424192964B16B3 /* AppSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13BE9781699FB510E9263192 /* AppSettingsHook.swift */; }; 4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */; }; 4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */; }; + 4F4144EE97F8E52D4225392B /* ExtendedProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB4E616E6405AA5DD5A04EA /* ExtendedProfileTests.swift */; }; 4F51C7C8131D060108B80575 /* NavigationCoordinators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79351405CD99312A182B2488 /* NavigationCoordinators.swift */; }; 4FBC9E416582352432EBAB7F /* ClientFactoryHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3561843E139EA7CDAED0933 /* ClientFactoryHook.swift */; }; 4FDC8A9764CFDA90CE035725 /* Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB2253D36E81E045E1CB432 /* Duration.swift */; }; @@ -598,6 +602,7 @@ 6189B4ABD535CE526FA1107B /* StartChatViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF438EAFC732D2D95D34BF6 /* StartChatViewModelTests.swift */; }; 61941DEE5F3834765770BE01 /* InviteUsersScreenSelectedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F32E0B4B83D2A11EE8D011 /* InviteUsersScreenSelectedItem.swift */; }; 61A36B9BB2ADE36CEFF5E98C /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E93A1BE7D8A2EBCAD51EEB4 /* Array.swift */; }; + 61A4401FF2B46AA7EF8B0CD8 /* ExtendedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF73F8BBCB34CC51C31995D /* ExtendedProfile.swift */; }; 62441024BDDA6B4CA86E584F /* test_audio.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 68F8744C32ACA0821A771EBB /* test_audio.mp3 */; }; 62684AECDFC5C7DC989CBD9E /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 7B6BC3219ADD8AA0311D2B86 /* SnapshotTesting */; }; 627139A3D79F032BA81E3A53 /* UserSessionFlowCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */; }; @@ -901,10 +906,13 @@ 8CFDC1309E8E5192EC2B04FE /* portrait_test_video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = D48D415C7A4284DEBEB34924 /* portrait_test_video.mp4 */; }; 8D24671992A1C1753B211221 /* EncryptionResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89BB11A792EF6F70B95B467E /* EncryptionResetTests.swift */; }; 8D3E1FADD78E72504DE0E402 /* UserAgentBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */; }; + 8D57FE274A1929697ACD8254 /* HealthChatAPIServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 994DECEF3C67C4A715B09C82 /* HealthChatAPIServiceProtocol.swift */; }; 8D71E5E53F372202379BECCE /* BugReportScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 303FCADE77DF1F3670C086ED /* BugReportScreenViewModel.swift */; }; + 8DB9BEBB357A9F27208A36EF /* HealthChatAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE02F1DAF9054BD6F960D893 /* HealthChatAPIModels.swift */; }; 8DC176CC5ABA24138EB443DD /* RoomMemberDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55679AF67545EF8087E47BE /* RoomMemberDetails.swift */; }; 8DCA1F05C3BA6ED826F1599D /* RoomSelectionScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B4E3F1265581683E4997B8 /* RoomSelectionScreenViewModel.swift */; }; 8DCD9CC5361FF22A5B2C20F1 /* AppLockSetupSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9FCE4D1E3A81AC1CC5CB91 /* AppLockSetupSettingsScreenCoordinator.swift */; }; + 8DE0906C2D4B3880D28CC8DA /* ExtendedProfileServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F8DC392F9D8DA27AAF3E5 /* ExtendedProfileServiceProtocol.swift */; }; 8DF0EBD97753033C715D716E /* RoomFlowCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407C8DD85179D2DB896FC0FA /* RoomFlowCoordinatorStateMachine.swift */; }; 8E650379587C31D7912ED67B /* UNNotification+Creator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */; }; 8E7A902CA16E24928F83646C /* ElementCallServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */; }; @@ -964,6 +972,7 @@ 962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */; }; 964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B902EA6CD3296B0E10EE432B /* HomeScreen.swift */; }; 9696ECAFB4F0C079C5C2A526 /* AppLockSetupPINScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAF8C2226A57B9AB7446B31 /* AppLockSetupPINScreenCoordinator.swift */; }; + 96AA8FAE9F2B3149DEA8496B /* HomeserverCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227A208A5E4E72117632EB01 /* HomeserverCredentials.swift */; }; 96B3606E30F824095B1DD022 /* NetworkMonitorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */; }; 9707AF8D41667FA9B35E8953 /* UserToInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED25719E19B205B668FDACFF /* UserToInvite.swift */; }; 97189E495F0E47805D1868DB /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 2B43F2AF7456567FE37270A7 /* KeychainAccess */; }; @@ -1077,6 +1086,7 @@ 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 */; }; + A87651F9995DE56EF727E24A /* HealthChatAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6004777518436121012B76CF /* HealthChatAPIService.swift */; }; A88328D7E17F73AB64501B51 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = EDFB92E97D9AC4BA8540C18C /* SwiftSoup */; }; A8DB299C5C891EDAE74A22F4 /* UserLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7482D5D1526E3399B00174 /* UserLocationCell.swift */; }; A8E324E700E596E36B0A311B /* BootDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F054DE7D47849687662C9D9 /* BootDetectionManager.swift */; }; @@ -1148,6 +1158,7 @@ B5E455C9689EA600EDB3E9E0 /* NavigationRootCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */; }; B6048166B4AA4CEFEA9B77A6 /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; }; B6064D82FCDCB829601C1F59 /* SecureBackupLogoutConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEE10AB666891E6A675E5E /* SecureBackupLogoutConfirmationScreen.swift */; }; + B6455EAB7D72B6021A1C5B86 /* ExtendedProfileServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AFBE494F9CCAAE8046203B /* ExtendedProfileServiceTests.swift */; }; B6B62437B92B6CA4083AA899 /* SessionDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2067FF58B4996323EB40C /* SessionDirectories.swift */; }; B6DA66EFC13A90846B625836 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 91DE43B8815918E590912DDA /* InfoPlist.strings */; }; B6DF6B6FA8734B70F9BF261E /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */; }; @@ -1178,6 +1189,7 @@ BB784A02BADB03C820617A46 /* TextRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90A55430639712CFACA34F43 /* TextRoomTimelineItem.swift */; }; BB9B800C6094E34860E89DC5 /* AppLockSetupBiometricsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CCF9A924521DECA44778C4 /* AppLockSetupBiometricsScreen.swift */; }; BC1222EDFF0C240F14259315 /* BloomModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D152423EE6CF0ECCC84091A /* BloomModifier.swift */; }; + BC38C35A60F164A7AE3743E4 /* HealthChatOpenIDTokenProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E3983406311A8BBE37A19A /* HealthChatOpenIDTokenProviderTests.swift */; }; BC5F94B10B40ABEC6046B473 /* ChatsTabFlowCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B94F1B0B5D9D42B15AA6E8 /* ChatsTabFlowCoordinatorStateMachine.swift */; }; BC7CA1379D7C24F47B1B8B7E /* PaginationIndicatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7F7A975514E850A834B29F /* PaginationIndicatorRoomTimelineView.swift */; }; BCA5E2157CE27AB6F1D043D3 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 5A8EF1A5F9629FCA309D4B2A /* AsyncAlgorithms */; }; @@ -1225,6 +1237,7 @@ C3BB6887CF13B19182E81F87 /* IdentityConfirmationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A03E073077D92AA19C43DCF /* IdentityConfirmationScreenCoordinator.swift */; }; C405528EB4BBEA93579050EE /* VoiceMessageRecordingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A2AD86F7E618F468F6FAF5 /* VoiceMessageRecordingButton.swift */; }; C4078364FD9FA00EA9D00A15 /* RoomMembersListScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CDF9A107BFE6C79B58D6B5 /* RoomMembersListScreenViewModelProtocol.swift */; }; + C40957497CAFADB846784A2A /* HealthChatAPIModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB71F86BB7A3AC9B32845B5B /* HealthChatAPIModelsTests.swift */; }; C413D36D44F89DE63D3ADFA4 /* ReportContentScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A433BE28B40D418237BE37B5 /* ReportContentScreen.swift */; }; C486DA7A3E05BAA1E389D280 /* AuthenticationStartScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41F69820F717FF11C6309916 /* AuthenticationStartScreenCoordinator.swift */; }; C49FCC766673006B6D299F1C /* RoomDetailsEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */; }; @@ -1572,6 +1585,7 @@ F8E725D42023ECA091349245 /* AudioRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57EAAF82432B0B53881CF826 /* AudioRoomTimelineItem.swift */; }; F8F47CE757EE656905F01F2C /* RoomRolesAndPermissionsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90DFF217B3D9D0941283278C /* RoomRolesAndPermissionsScreenViewModelProtocol.swift */; }; F9842667B68DC6FA1F9ECCBB /* NSItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F72EFC8C634469F9262659C7 /* NSItemProvider.swift */; }; + F995036BBA39691CF7AD1F08 /* HealthChatAPIServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 087194751572DF7CA65D1CCB /* HealthChatAPIServiceTests.swift */; }; F996259EAE68664BC345C197 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DBE1C69F3A60D93B2203F /* Application.swift */; }; F99FB21EFC6D99D247FE7CBE /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 19CD5B074D7DD44AF4C58BB6 /* SwiftState */; }; F9E5FF20B50705EB13137778 /* SpaceAddRoomsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59EBB553F8EFDD149742DC70 /* SpaceAddRoomsScreenViewModelProtocol.swift */; }; @@ -1604,6 +1618,7 @@ FF34BF2AF731340AF9414A18 /* SwipeRightAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4552D3466B1453F287223ADA /* SwipeRightAction.swift */; }; FF7E8ECC8E7E1D1851517536 /* PollFormScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347D708104CCEF771428C9A3 /* PollFormScreenViewModelTests.swift */; }; FF9C06BBF6AC6F1CFFBEBFFC /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 90791B9C739C716A40E1B230 /* target.yml */; }; + FFC6A8BD47F0DA24A2E98B1A /* OpenIDToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B7938C8D756559003D4AB4 /* OpenIDToken.swift */; }; FFD52DCDA6962055A363CC8F /* IdentityResetHandleSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFB1D29B0870AFB6A56E9B8 /* IdentityResetHandleSDKMock.swift */; }; FFDF5C43A6A25E54F3FF4CC8 /* test_apple_image.heic in Resources */ = {isa = PBXBuildFile; fileRef = 9C5639C7D679F094E7AB6B04 /* test_apple_image.heic */; }; /* End PBXBuildFile section */ @@ -1794,6 +1809,7 @@ 0825EAFD47332DD459DE893F /* SessionDirectoriesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDirectoriesTests.swift; sourceTree = ""; }; 08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelProtocol.swift; sourceTree = ""; }; 0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsFlowCoordinator.swift; sourceTree = ""; }; + 087194751572DF7CA65D1CCB /* HealthChatAPIServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIServiceTests.swift; sourceTree = ""; }; 094F6B21835890B470DF540C /* SpaceScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenCoordinator.swift; sourceTree = ""; }; 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = ""; }; 0A2074C0449B83D5858BD2D7 /* FrequentlyUsedEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequentlyUsedEmoji.swift; sourceTree = ""; }; @@ -1852,6 +1868,7 @@ 113356152C099951A6D17D85 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; 113873B97F27394ABE41BCFD /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; }; 118892B93A8CFDD691D9B5E3 /* RoomLiveLocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomLiveLocationService.swift; sourceTree = ""; }; + 11E3983406311A8BBE37A19A /* HealthChatOpenIDTokenProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatOpenIDTokenProviderTests.swift; sourceTree = ""; }; 11FCAE847556719BBE7A0882 /* ReportRoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreenModels.swift; sourceTree = ""; }; 1215A4FC53D2319E81AE8970 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; 1222DB76B917EB8A55365BA5 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; @@ -1888,6 +1905,7 @@ 176A581FDC61737676782607 /* ClientFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientFactoryProtocol.swift; sourceTree = ""; }; 17A8AA0DFA06012A9DAB951E /* TimelineProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProxyMock.swift; sourceTree = ""; }; 17BAE25A0E9E9F2F1BBA8930 /* DeactivateAccountScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeactivateAccountScreenViewModel.swift; sourceTree = ""; }; + 180F8DC392F9D8DA27AAF3E5 /* ExtendedProfileServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfileServiceProtocol.swift; sourceTree = ""; }; 181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePreferenceTests.swift; sourceTree = ""; }; 18486B87745B1811E7FBD3D2 /* AnalyticsPromptScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenModels.swift; sourceTree = ""; }; 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorationTimelineItemProtocol.swift; sourceTree = ""; }; @@ -1959,6 +1977,7 @@ 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFlowTests.swift; sourceTree = ""; }; 226A56746AD99ABDA97189E1 /* SpaceServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceServiceProxy.swift; sourceTree = ""; }; 22730A30C50AC2E3D5BA8642 /* InviteUsersScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenViewModelProtocol.swift; sourceTree = ""; }; + 227A208A5E4E72117632EB01 /* HomeserverCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeserverCredentials.swift; sourceTree = ""; }; 227AC5D71A4CE43512062243 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 22B384D54464FA39C6C7F6E7 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ca; path = ca.lproj/Localizable.stringsdict; sourceTree = ""; }; 233D5F7E5E9F49ABF3413291 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = hr; path = hr.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -2314,11 +2333,13 @@ 5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = ""; }; 5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = ""; }; + 5EF73F8BBCB34CC51C31995D /* ExtendedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfile.swift; sourceTree = ""; }; 5EFB1D29B0870AFB6A56E9B8 /* IdentityResetHandleSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityResetHandleSDKMock.swift; sourceTree = ""; }; 5F12E996BFBEB43815189ABF /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; 5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionProtocol.swift; sourceTree = ""; }; 5FACD034DB52525A3CEF2BDF /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = ""; }; 60011EF0086E49DBD78E16E5 /* ResolveVerifiedUserSendFailureScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenModels.swift; sourceTree = ""; }; + 6004777518436121012B76CF /* HealthChatAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIService.swift; sourceTree = ""; }; 6033779EB37259F27F938937 /* ClientProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxyProtocol.swift; sourceTree = ""; }; 604A69C081B935D6A38DE6D8 /* UserProfileScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileScreenModels.swift; sourceTree = ""; }; 60C9BAE9F9436B14E4E22E8F /* PinnedItemsBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedItemsBannerView.swift; sourceTree = ""; }; @@ -2406,6 +2427,7 @@ 70C86696AC9521F8ED88FBEB /* MediaUploadPreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreen.swift; sourceTree = ""; }; 70F8DAEF1A8131BDFD4CDE83 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/InfoPlist.strings; sourceTree = ""; }; 713B48DBF65DE4B0DD445D66 /* ReportContentScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenViewModelProtocol.swift; sourceTree = ""; }; + 715B60D68D20808E328DA1DA /* HealthChatSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatSettings.swift; sourceTree = ""; }; 7168FDF4B2B394142FEE2A76 /* PresenceServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceServiceTests.swift; sourceTree = ""; }; 718D8767035D37E2DB5CC550 /* QRCodeLoginScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreenViewModelProtocol.swift; sourceTree = ""; }; 7199693797B66245EF97BCF5 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2455,6 +2477,7 @@ 79351405CD99312A182B2488 /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = ""; }; 796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenCoordinator.swift; sourceTree = ""; }; 79A106D76741914F82664959 /* StateStoreViewModelV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateStoreViewModelV2.swift; sourceTree = ""; }; + 79AFBE494F9CCAAE8046203B /* ExtendedProfileServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfileServiceTests.swift; sourceTree = ""; }; 7A03E073077D92AA19C43DCF /* IdentityConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenCoordinator.swift; sourceTree = ""; }; 7A60C2712F5607406E8B1C80 /* HealthChatBrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatBrandColors.swift; sourceTree = ""; }; 7A6D867A7FBB70C6EFDBCBC5 /* AccessibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityTests.swift; sourceTree = ""; }; @@ -2569,6 +2592,7 @@ 8A1F2AAA3F0F2B72D2FFE4D0 /* MapTilerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerConfiguration.swift; sourceTree = ""; }; 8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationRoomTimelineItem.swift; sourceTree = ""; }; 8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 8AB4E616E6405AA5DD5A04EA /* ExtendedProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfileTests.swift; sourceTree = ""; }; 8AE0C9653870803E4F91F474 /* RoomListFiltersStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFiltersStateTests.swift; sourceTree = ""; }; 8AE78FA0011E07920AE83135 /* PlainMentionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainMentionBuilder.swift; sourceTree = ""; }; 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoader.swift; sourceTree = ""; }; @@ -2649,6 +2673,7 @@ 98ABC939BC8F08CA3E967D6C /* JoinCallButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinCallButton.swift; sourceTree = ""; }; 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemThreadSummary.swift; sourceTree = ""; }; 98F4DFFFC62187D9A4D2030D /* ClassicAppAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppAccountManager.swift; sourceTree = ""; }; + 994DECEF3C67C4A715B09C82 /* HealthChatAPIServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIServiceProtocol.swift; sourceTree = ""; }; 996AF8CF3FCF19B62F005C57 /* ClientFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientFactory.swift; sourceTree = ""; }; 997BF045585AF6DB2EBC5755 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; 99EB08B2C8B01F3BFE4B0CEC /* RoomMemberDetailsScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenHook.swift; sourceTree = ""; }; @@ -2706,6 +2731,7 @@ A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryPicker.swift; sourceTree = ""; }; A243A6E6207297123E60DE48 /* TimelineItemMacContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMacContextMenu.swift; sourceTree = ""; }; A2723A4AF3AB9F5E18D26E49 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/InfoPlist.strings; sourceTree = ""; }; + A2B7938C8D756559003D4AB4 /* OpenIDToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenIDToken.swift; sourceTree = ""; }; A3A4F56855188DAD23E59031 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; A3B4B58B79A6FA250B24A1EC /* HomeScreenContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenContent.swift; sourceTree = ""; }; A3E3DE6616DCA81AD5F4791B /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2951,6 +2977,7 @@ 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 = ""; }; CB04B2D794885025DACFCEFB /* SnapshotableGlassEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotableGlassEffect.swift; sourceTree = ""; }; + CB71F86BB7A3AC9B32845B5B /* HealthChatAPIModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIModelsTests.swift; sourceTree = ""; }; CB7B588A06911B455AC0B4C9 /* ManageRoomMemberSheetViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageRoomMemberSheetViewModelProtocol.swift; sourceTree = ""; }; CB98BFD8E93C7FCCEDEC46F9 /* SpacesScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacesScreenViewModel.swift; sourceTree = ""; }; CBBCC6E74774E79B599625D0 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; @@ -2970,6 +2997,7 @@ CDA38C9B58693A49A3032552 /* DeclineAndBlockScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenCoordinator.swift; sourceTree = ""; }; CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = ""; }; CDE3F3911FF7CC639BDE5844 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + CE02F1DAF9054BD6F960D893 /* HealthChatAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIModels.swift; sourceTree = ""; }; CEE20623EB4A9B88FB29F2BA /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/SAS.strings; sourceTree = ""; }; CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = ""; }; CF19027E7FFA5E63D148873A /* CreateRoomScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreenViewModel.swift; sourceTree = ""; }; @@ -3033,6 +3061,7 @@ D8F5F9E02B1AB5350B1815E7 /* TimelineStartRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineItem.swift; sourceTree = ""; }; D8FC33C3F6BF597E095CE9FA /* HomeScreenInviteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenInviteCell.swift; sourceTree = ""; }; D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecorderTests.swift; sourceTree = ""; }; + D94248E57F753281A220BBCC /* HealthChatOpenIDTokenProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatOpenIDTokenProvider.swift; sourceTree = ""; }; D95E8C0EFEC0C6F96EDAA71A /* PreviewTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = PreviewTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D97A4E73EA97CA08D2BB9806 /* RoomScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenTests.swift; sourceTree = ""; }; D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreen.swift; sourceTree = ""; }; @@ -3102,6 +3131,7 @@ E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; E53BFB7E4F329621C844E8C3 /* AnalyticsPromptScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreen.swift; sourceTree = ""; }; E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsProxyProtocol.swift; sourceTree = ""; }; + E5A1F965895E27CAEA93B58A /* ExtendedProfileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfileService.swift; sourceTree = ""; }; E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerProtocol.swift; sourceTree = ""; }; E5F2B6443D1ED8602F328539 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ru; path = ru.lproj/Localizable.stringsdict; sourceTree = ""; }; E5FDFAA04174CC99FB66391C /* EditRoomAddressScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditRoomAddressScreenViewModel.swift; sourceTree = ""; }; @@ -3518,6 +3548,7 @@ 3A9A60666ED97CF8D4858D80 /* ContentScanner */, 92E99C57D7F92ED16F73282C /* ElementCall */, 39557ADF21345E18F3865B9E /* Emojis */, + BC546E788D4D4B9D84B7A6E1 /* HealthChat */, CA555F7C7CA382ACACF0D82B /* Keychain */, DCF22D1E268F1A36EC3431E4 /* LinkMetadata */, 90FD376E9373246E4925CE95 /* Location */, @@ -5318,6 +5349,7 @@ AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */, D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */, 9613851C68D8C01EABFB3569 /* AppLock */, + 86301892A569A75EC5ADDCF2 /* HealthChat */, A6AA0A048CAE428A5CA4CBBB /* LayoutTests */, 7583EAC171059A86B767209F /* MediaProvider */, 7DBC911559934065993A5FF4 /* NotificationManager */, @@ -5579,6 +5611,7 @@ E1ECC44D40D05D3A6F0DE0F7 /* HealthChatAppSettingsHook.swift */, 7A60C2712F5607406E8B1C80 /* HealthChatBrandColors.swift */, E09E2B1AB937F2D01815495F /* HealthChatCompoundHook.swift */, + 715B60D68D20808E328DA1DA /* HealthChatSettings.swift */, ); path = HealthChat; sourceTree = ""; @@ -5591,7 +5624,9 @@ 176A581FDC61737676782607 /* ClientFactoryProtocol.swift */, 18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */, 6033779EB37259F27F938937 /* ClientProxyProtocol.swift */, + 227A208A5E4E72117632EB01 /* HomeserverCredentials.swift */, 6D2BBDB7E80E119D5C906285 /* HomeserverHistoryManager.swift */, + A2B7938C8D756559003D4AB4 /* OpenIDToken.swift */, ); path = Client; sourceTree = ""; @@ -5713,6 +5748,18 @@ path = Messages; sourceTree = ""; }; + 86301892A569A75EC5ADDCF2 /* HealthChat */ = { + isa = PBXGroup; + children = ( + 79AFBE494F9CCAAE8046203B /* ExtendedProfileServiceTests.swift */, + 8AB4E616E6405AA5DD5A04EA /* ExtendedProfileTests.swift */, + CB71F86BB7A3AC9B32845B5B /* HealthChatAPIModelsTests.swift */, + 087194751572DF7CA65D1CCB /* HealthChatAPIServiceTests.swift */, + 11E3983406311A8BBE37A19A /* HealthChatOpenIDTokenProviderTests.swift */, + ); + path = HealthChat; + sourceTree = ""; + }; 864330656491EBAADA4901D3 /* Sources */ = { isa = PBXGroup; children = ( @@ -6500,6 +6547,20 @@ path = View; sourceTree = ""; }; + BC546E788D4D4B9D84B7A6E1 /* HealthChat */ = { + isa = PBXGroup; + children = ( + 5EF73F8BBCB34CC51C31995D /* ExtendedProfile.swift */, + E5A1F965895E27CAEA93B58A /* ExtendedProfileService.swift */, + 180F8DC392F9D8DA27AAF3E5 /* ExtendedProfileServiceProtocol.swift */, + CE02F1DAF9054BD6F960D893 /* HealthChatAPIModels.swift */, + 6004777518436121012B76CF /* HealthChatAPIService.swift */, + 994DECEF3C67C4A715B09C82 /* HealthChatAPIServiceProtocol.swift */, + D94248E57F753281A220BBCC /* HealthChatOpenIDTokenProvider.swift */, + ); + path = HealthChat; + sourceTree = ""; + }; BC8F2F197AFDABE63B2E2CA7 /* View */ = { isa = PBXGroup; children = ( @@ -8317,7 +8378,12 @@ 7AE25D29734267271106D732 /* EmojiPickerScreenViewModelTests.swift in Sources */, 25618589E0DE0F1E95FC7B5C /* EmojiProviderTests.swift in Sources */, 71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */, + B6455EAB7D72B6021A1C5B86 /* ExtendedProfileServiceTests.swift in Sources */, + 4F4144EE97F8E52D4225392B /* ExtendedProfileTests.swift in Sources */, 07756D532EFE33DD1FA258E5 /* GeoURITests.swift in Sources */, + C40957497CAFADB846784A2A /* HealthChatAPIModelsTests.swift in Sources */, + F995036BBA39691CF7AD1F08 /* HealthChatAPIServiceTests.swift in Sources */, + BC38C35A60F164A7AE3743E4 /* HealthChatOpenIDTokenProviderTests.swift in Sources */, 6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */, F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */, 723382158BFD148984201266 /* HomeserverHistoryManagerTests.swift in Sources */, @@ -8828,6 +8894,9 @@ 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */, BED59052E5C5163D2B065CA6 /* EventTimelineItem.swift in Sources */, 63E46D18B91D08E15FC04125 /* ExpiringTaskRunner.swift in Sources */, + 61A4401FF2B46AA7EF8B0CD8 /* ExtendedProfile.swift in Sources */, + 41B72420FE80DCD1FD115D47 /* ExtendedProfileService.swift in Sources */, + 8DE0906C2D4B3880D28CC8DA /* ExtendedProfileServiceProtocol.swift in Sources */, 36206F74DDEBF9BEAF6A6A1F /* ExtensionLogger.swift in Sources */, 5F06AD3C66884CE793AE6119 /* FileManager.swift in Sources */, EA8D941771E762A5D3D7FA0D /* FileMediaEventsTimelineView.swift in Sources */, @@ -8851,10 +8920,15 @@ 4295E5F850897710A51AE114 /* GeoURI.swift in Sources */, F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */, E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */, + 8DB9BEBB357A9F27208A36EF /* HealthChatAPIModels.swift in Sources */, + A87651F9995DE56EF727E24A /* HealthChatAPIService.swift in Sources */, + 8D57FE274A1929697ACD8254 /* HealthChatAPIServiceProtocol.swift in Sources */, E7F27870397639C204402434 /* HealthChatAppHooks.swift in Sources */, 665314B4CB31904F107D2D3B /* HealthChatAppSettingsHook.swift in Sources */, 6DDA932A28F531791BDBFB08 /* HealthChatBrandColors.swift in Sources */, 95C338B0F2101D8A8CA16A98 /* HealthChatCompoundHook.swift in Sources */, + 4B5E2E8FBE54B73F79E607BC /* HealthChatOpenIDTokenProvider.swift in Sources */, + 1E822B072359DD37EEA8CE78 /* HealthChatSettings.swift in Sources */, 0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */, 964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */, 62C5876C4254C58C2086F0DE /* HomeScreenContent.swift in Sources */, @@ -8871,6 +8945,7 @@ 56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */, C0A845AAFEB7E1BEAE04306E /* HomeserverCapabilitiesProxy.swift in Sources */, 97550ECE8A1D28FEF74FBACC /* HomeserverCapabilitiesProxyProtocol.swift in Sources */, + 96AA8FAE9F2B3149DEA8496B /* HomeserverCredentials.swift in Sources */, 34320DB3C585FD34F4DEC5C8 /* HomeserverHistoryManager.swift in Sources */, 277FD4394EAFF323DA34997E /* HorizontalHighlightGradient.swift in Sources */, 2BBE320EE426A347AAE5C7DA /* IdentityConfirmationScreen.swift in Sources */, @@ -9101,6 +9176,7 @@ 68B7308BA24EC3DC5FD87B61 /* OAuthPresenterHook.swift in Sources */, FD573B5D665824EB79EABF06 /* Observable.swift in Sources */, 11A6B8E3CBDBF0A4107FF4CE /* OnboardingFlowCoordinator.swift in Sources */, + FFC6A8BD47F0DA24A2E98B1A /* OpenIDToken.swift in Sources */, 3CE4C5071B6D2576E2473989 /* OrderedSet.swift in Sources */, AA5924D3B67F7ACD98BBEFDC /* OrientationManagerProtocol.swift in Sources */, 804C15D8ADE0EA7A5268F58A /* OverridableAvatarImage.swift in Sources */, diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatSettings.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatSettings.swift new file mode 100644 index 000000000..5f6fd242c --- /dev/null +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatSettings.swift @@ -0,0 +1,14 @@ +// +// 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 Foundation + +/// Fork-only constants for the HealthChat backend. Injected through service +/// initialisers so tests can point them at a mock server. +enum HealthChatSettings { + static let apiBaseURL: URL = "https://api.healthchat.ch" +} diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 5fc527511..647adde6d 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -3879,6 +3879,62 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { return profileForReturnValue } } + //MARK: - requestOpenIDToken + + private let requestOpenIDTokenCallsCountLock = NSLock() + private nonisolated(unsafe) var requestOpenIDTokenUnderlyingCallsCount = 0 + var requestOpenIDTokenCallsCount: Int { + get { requestOpenIDTokenCallsCountLock.withLock { requestOpenIDTokenUnderlyingCallsCount } } + set { requestOpenIDTokenCallsCountLock.withLock { requestOpenIDTokenUnderlyingCallsCount = newValue } } + } + var requestOpenIDTokenCalled: Bool { + return requestOpenIDTokenCallsCount > 0 + } + + private let requestOpenIDTokenReturnValueLock = NSLock() + private nonisolated(unsafe) var requestOpenIDTokenUnderlyingReturnValue: Result! + var requestOpenIDTokenReturnValue: Result! { + get { requestOpenIDTokenReturnValueLock.withLock { requestOpenIDTokenUnderlyingReturnValue } } + set { requestOpenIDTokenReturnValueLock.withLock { requestOpenIDTokenUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var requestOpenIDTokenClosure: (() async -> Result)? + + @concurrent func requestOpenIDToken() async -> Result { + requestOpenIDTokenCallsCountLock.withLock { requestOpenIDTokenUnderlyingCallsCount += 1 } + if let requestOpenIDTokenClosure = requestOpenIDTokenClosure { + return await requestOpenIDTokenClosure() + } else { + return requestOpenIDTokenReturnValue + } + } + //MARK: - homeserverCredentials + + private let homeserverCredentialsCallsCountLock = NSLock() + private nonisolated(unsafe) var homeserverCredentialsUnderlyingCallsCount = 0 + var homeserverCredentialsCallsCount: Int { + get { homeserverCredentialsCallsCountLock.withLock { homeserverCredentialsUnderlyingCallsCount } } + set { homeserverCredentialsCallsCountLock.withLock { homeserverCredentialsUnderlyingCallsCount = newValue } } + } + var homeserverCredentialsCalled: Bool { + return homeserverCredentialsCallsCount > 0 + } + + private let homeserverCredentialsReturnValueLock = NSLock() + private nonisolated(unsafe) var homeserverCredentialsUnderlyingReturnValue: Result! + var homeserverCredentialsReturnValue: Result! { + get { homeserverCredentialsReturnValueLock.withLock { homeserverCredentialsUnderlyingReturnValue } } + set { homeserverCredentialsReturnValueLock.withLock { homeserverCredentialsUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var homeserverCredentialsClosure: (() -> Result)? + + func homeserverCredentials() -> Result { + homeserverCredentialsCallsCountLock.withLock { homeserverCredentialsUnderlyingCallsCount += 1 } + if let homeserverCredentialsClosure = homeserverCredentialsClosure { + return homeserverCredentialsClosure() + } else { + return homeserverCredentialsReturnValue + } + } //MARK: - roomDirectorySearchProxy private let roomDirectorySearchProxyCallsCountLock = NSLock() @@ -5483,6 +5539,284 @@ nonisolated class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol, } } } +nonisolated class ExtendedProfileServiceMock: ExtendedProfileServiceProtocol, @unchecked Sendable { + + //MARK: - profile + + private let profileForCallsCountLock = NSLock() + private nonisolated(unsafe) var profileForUnderlyingCallsCount = 0 + var profileForCallsCount: Int { + get { profileForCallsCountLock.withLock { profileForUnderlyingCallsCount } } + set { profileForCallsCountLock.withLock { profileForUnderlyingCallsCount = newValue } } + } + var profileForCalled: Bool { + return profileForCallsCount > 0 + } + private let profileForReceivedUserIDLock = NSLock() + private nonisolated(unsafe) var profileForUnderlyingReceivedUserID: String? + var profileForReceivedUserID: String? { + get { profileForReceivedUserIDLock.withLock { profileForUnderlyingReceivedUserID } } + set { profileForReceivedUserIDLock.withLock { profileForUnderlyingReceivedUserID = newValue } } + } + private let profileForReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var profileForUnderlyingReceivedInvocations: [String] = [] + var profileForReceivedInvocations: [String] { + get { profileForReceivedInvocationsLock.withLock { profileForUnderlyingReceivedInvocations } } + set { profileForReceivedInvocationsLock.withLock { profileForUnderlyingReceivedInvocations = newValue } } + } + + private let profileForReturnValueLock = NSLock() + private nonisolated(unsafe) var profileForUnderlyingReturnValue: Result! + var profileForReturnValue: Result! { + get { profileForReturnValueLock.withLock { profileForUnderlyingReturnValue } } + set { profileForReturnValueLock.withLock { profileForUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var profileForClosure: ((String) async -> Result)? + + @concurrent func profile(for userID: String) async -> Result { + profileForCallsCountLock.withLock { profileForUnderlyingCallsCount += 1 } + profileForReceivedUserID = userID + profileForReceivedInvocationsLock.withLock { profileForUnderlyingReceivedInvocations.append(userID) } + if let profileForClosure = profileForClosure { + return await profileForClosure(userID) + } else { + return profileForReturnValue + } + } +} +nonisolated class HealthChatAPIServiceMock: HealthChatAPIServiceProtocol, @unchecked Sendable { + + //MARK: - members + + private let membersSearchQueryCallsCountLock = NSLock() + private nonisolated(unsafe) var membersSearchQueryUnderlyingCallsCount = 0 + var membersSearchQueryCallsCount: Int { + get { membersSearchQueryCallsCountLock.withLock { membersSearchQueryUnderlyingCallsCount } } + set { membersSearchQueryCallsCountLock.withLock { membersSearchQueryUnderlyingCallsCount = newValue } } + } + var membersSearchQueryCalled: Bool { + return membersSearchQueryCallsCount > 0 + } + private let membersSearchQueryReceivedSearchQueryLock = NSLock() + private nonisolated(unsafe) var membersSearchQueryUnderlyingReceivedSearchQuery: String? + var membersSearchQueryReceivedSearchQuery: String? { + get { membersSearchQueryReceivedSearchQueryLock.withLock { membersSearchQueryUnderlyingReceivedSearchQuery } } + set { membersSearchQueryReceivedSearchQueryLock.withLock { membersSearchQueryUnderlyingReceivedSearchQuery = newValue } } + } + private let membersSearchQueryReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var membersSearchQueryUnderlyingReceivedInvocations: [String?] = [] + var membersSearchQueryReceivedInvocations: [String?] { + get { membersSearchQueryReceivedInvocationsLock.withLock { membersSearchQueryUnderlyingReceivedInvocations } } + set { membersSearchQueryReceivedInvocationsLock.withLock { membersSearchQueryUnderlyingReceivedInvocations = newValue } } + } + + private let membersSearchQueryReturnValueLock = NSLock() + private nonisolated(unsafe) var membersSearchQueryUnderlyingReturnValue: Result! + var membersSearchQueryReturnValue: Result! { + get { membersSearchQueryReturnValueLock.withLock { membersSearchQueryUnderlyingReturnValue } } + set { membersSearchQueryReturnValueLock.withLock { membersSearchQueryUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var membersSearchQueryClosure: ((String?) async -> Result)? + + @concurrent func members(searchQuery: String?) async -> Result { + membersSearchQueryCallsCountLock.withLock { membersSearchQueryUnderlyingCallsCount += 1 } + membersSearchQueryReceivedSearchQuery = searchQuery + membersSearchQueryReceivedInvocationsLock.withLock { membersSearchQueryUnderlyingReceivedInvocations.append(searchQuery) } + if let membersSearchQueryClosure = membersSearchQueryClosure { + return await membersSearchQueryClosure(searchQuery) + } else { + return membersSearchQueryReturnValue + } + } + //MARK: - ownProfile + + private let ownProfileCallsCountLock = NSLock() + private nonisolated(unsafe) var ownProfileUnderlyingCallsCount = 0 + var ownProfileCallsCount: Int { + get { ownProfileCallsCountLock.withLock { ownProfileUnderlyingCallsCount } } + set { ownProfileCallsCountLock.withLock { ownProfileUnderlyingCallsCount = newValue } } + } + var ownProfileCalled: Bool { + return ownProfileCallsCount > 0 + } + + private let ownProfileReturnValueLock = NSLock() + private nonisolated(unsafe) var ownProfileUnderlyingReturnValue: Result! + var ownProfileReturnValue: Result! { + get { ownProfileReturnValueLock.withLock { ownProfileUnderlyingReturnValue } } + set { ownProfileReturnValueLock.withLock { ownProfileUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var ownProfileClosure: (() async -> Result)? + + @concurrent func ownProfile() async -> Result { + ownProfileCallsCountLock.withLock { ownProfileUnderlyingCallsCount += 1 } + if let ownProfileClosure = ownProfileClosure { + return await ownProfileClosure() + } else { + return ownProfileReturnValue + } + } + //MARK: - updateOwnProfile + + private let updateOwnProfileCallsCountLock = NSLock() + private nonisolated(unsafe) var updateOwnProfileUnderlyingCallsCount = 0 + var updateOwnProfileCallsCount: Int { + get { updateOwnProfileCallsCountLock.withLock { updateOwnProfileUnderlyingCallsCount } } + set { updateOwnProfileCallsCountLock.withLock { updateOwnProfileUnderlyingCallsCount = newValue } } + } + var updateOwnProfileCalled: Bool { + return updateOwnProfileCallsCount > 0 + } + private let updateOwnProfileReceivedUpdateLock = NSLock() + private nonisolated(unsafe) var updateOwnProfileUnderlyingReceivedUpdate: HealthChatProfileUpdate? + var updateOwnProfileReceivedUpdate: HealthChatProfileUpdate? { + get { updateOwnProfileReceivedUpdateLock.withLock { updateOwnProfileUnderlyingReceivedUpdate } } + set { updateOwnProfileReceivedUpdateLock.withLock { updateOwnProfileUnderlyingReceivedUpdate = newValue } } + } + private let updateOwnProfileReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var updateOwnProfileUnderlyingReceivedInvocations: [HealthChatProfileUpdate] = [] + var updateOwnProfileReceivedInvocations: [HealthChatProfileUpdate] { + get { updateOwnProfileReceivedInvocationsLock.withLock { updateOwnProfileUnderlyingReceivedInvocations } } + set { updateOwnProfileReceivedInvocationsLock.withLock { updateOwnProfileUnderlyingReceivedInvocations = newValue } } + } + + private let updateOwnProfileReturnValueLock = NSLock() + private nonisolated(unsafe) var updateOwnProfileUnderlyingReturnValue: Result! + var updateOwnProfileReturnValue: Result! { + get { updateOwnProfileReturnValueLock.withLock { updateOwnProfileUnderlyingReturnValue } } + set { updateOwnProfileReturnValueLock.withLock { updateOwnProfileUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var updateOwnProfileClosure: ((HealthChatProfileUpdate) async -> Result)? + + @concurrent func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result { + updateOwnProfileCallsCountLock.withLock { updateOwnProfileUnderlyingCallsCount += 1 } + updateOwnProfileReceivedUpdate = update + updateOwnProfileReceivedInvocationsLock.withLock { updateOwnProfileUnderlyingReceivedInvocations.append(update) } + if let updateOwnProfileClosure = updateOwnProfileClosure { + return await updateOwnProfileClosure(update) + } else { + return updateOwnProfileReturnValue + } + } + //MARK: - profileCompleteness + + private let profileCompletenessCallsCountLock = NSLock() + private nonisolated(unsafe) var profileCompletenessUnderlyingCallsCount = 0 + var profileCompletenessCallsCount: Int { + get { profileCompletenessCallsCountLock.withLock { profileCompletenessUnderlyingCallsCount } } + set { profileCompletenessCallsCountLock.withLock { profileCompletenessUnderlyingCallsCount = newValue } } + } + var profileCompletenessCalled: Bool { + return profileCompletenessCallsCount > 0 + } + + private let profileCompletenessReturnValueLock = NSLock() + private nonisolated(unsafe) var profileCompletenessUnderlyingReturnValue: Result! + var profileCompletenessReturnValue: Result! { + get { profileCompletenessReturnValueLock.withLock { profileCompletenessUnderlyingReturnValue } } + set { profileCompletenessReturnValueLock.withLock { profileCompletenessUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var profileCompletenessClosure: (() async -> Result)? + + @concurrent func profileCompleteness() async -> Result { + profileCompletenessCallsCountLock.withLock { profileCompletenessUnderlyingCallsCount += 1 } + if let profileCompletenessClosure = profileCompletenessClosure { + return await profileCompletenessClosure() + } else { + return profileCompletenessReturnValue + } + } + //MARK: - inviteMember + + private let inviteMemberEmailPersonalNoteCallsCountLock = NSLock() + private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingCallsCount = 0 + var inviteMemberEmailPersonalNoteCallsCount: Int { + get { inviteMemberEmailPersonalNoteCallsCountLock.withLock { inviteMemberEmailPersonalNoteUnderlyingCallsCount } } + set { inviteMemberEmailPersonalNoteCallsCountLock.withLock { inviteMemberEmailPersonalNoteUnderlyingCallsCount = newValue } } + } + var inviteMemberEmailPersonalNoteCalled: Bool { + return inviteMemberEmailPersonalNoteCallsCount > 0 + } + private let inviteMemberEmailPersonalNoteReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingReceivedArguments: (email: String, personalNote: String)? + var inviteMemberEmailPersonalNoteReceivedArguments: (email: String, personalNote: String)? { + get { inviteMemberEmailPersonalNoteReceivedArgumentsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedArguments } } + set { inviteMemberEmailPersonalNoteReceivedArgumentsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedArguments = newValue } } + } + private let inviteMemberEmailPersonalNoteReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations: [(email: String, personalNote: String)] = [] + var inviteMemberEmailPersonalNoteReceivedInvocations: [(email: String, personalNote: String)] { + get { inviteMemberEmailPersonalNoteReceivedInvocationsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations } } + set { inviteMemberEmailPersonalNoteReceivedInvocationsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations = newValue } } + } + + private let inviteMemberEmailPersonalNoteReturnValueLock = NSLock() + private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingReturnValue: Result! + var inviteMemberEmailPersonalNoteReturnValue: Result! { + get { inviteMemberEmailPersonalNoteReturnValueLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReturnValue } } + set { inviteMemberEmailPersonalNoteReturnValueLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var inviteMemberEmailPersonalNoteClosure: ((String, String) async -> Result)? + + @concurrent func inviteMember(email: String, personalNote: String) async -> Result { + inviteMemberEmailPersonalNoteCallsCountLock.withLock { inviteMemberEmailPersonalNoteUnderlyingCallsCount += 1 } + inviteMemberEmailPersonalNoteReceivedArguments = (email: email, personalNote: personalNote) + inviteMemberEmailPersonalNoteReceivedInvocationsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations.append((email: email, personalNote: personalNote)) } + if let inviteMemberEmailPersonalNoteClosure = inviteMemberEmailPersonalNoteClosure { + return await inviteMemberEmailPersonalNoteClosure(email, personalNote) + } else { + return inviteMemberEmailPersonalNoteReturnValue + } + } +} +nonisolated class HealthChatOpenIDTokenProviderMock: HealthChatOpenIDTokenProviderProtocol, @unchecked Sendable { + + //MARK: - validToken + + private let validTokenCallsCountLock = NSLock() + private nonisolated(unsafe) var validTokenUnderlyingCallsCount = 0 + var validTokenCallsCount: Int { + get { validTokenCallsCountLock.withLock { validTokenUnderlyingCallsCount } } + set { validTokenCallsCountLock.withLock { validTokenUnderlyingCallsCount = newValue } } + } + var validTokenCalled: Bool { + return validTokenCallsCount > 0 + } + + private let validTokenReturnValueLock = NSLock() + private nonisolated(unsafe) var validTokenUnderlyingReturnValue: Result! + var validTokenReturnValue: Result! { + get { validTokenReturnValueLock.withLock { validTokenUnderlyingReturnValue } } + set { validTokenReturnValueLock.withLock { validTokenUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var validTokenClosure: (() async -> Result)? + + @concurrent func validToken() async -> Result { + validTokenCallsCountLock.withLock { validTokenUnderlyingCallsCount += 1 } + if let validTokenClosure = validTokenClosure { + return await validTokenClosure() + } else { + return validTokenReturnValue + } + } + //MARK: - invalidateToken + + private let invalidateTokenCallsCountLock = NSLock() + private nonisolated(unsafe) var invalidateTokenUnderlyingCallsCount = 0 + var invalidateTokenCallsCount: Int { + get { invalidateTokenCallsCountLock.withLock { invalidateTokenUnderlyingCallsCount } } + set { invalidateTokenCallsCountLock.withLock { invalidateTokenUnderlyingCallsCount = newValue } } + } + var invalidateTokenCalled: Bool { + return invalidateTokenCallsCount > 0 + } + nonisolated(unsafe) var invalidateTokenClosure: (() -> Void)? + + func invalidateToken() { + invalidateTokenCallsCountLock.withLock { invalidateTokenUnderlyingCallsCount += 1 } + invalidateTokenClosure?() + } +} nonisolated class HomeserverCapabilitiesProxyMock: HomeserverCapabilitiesProxyProtocol, @unchecked Sendable { //MARK: - refresh diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index e71fd1c67..1ddc0f15e 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -871,6 +871,28 @@ class ClientProxy: ClientProxyProtocol { } } + func requestOpenIDToken() async -> Result { + do { + return try await .success(.init(rustValue: client.requestOpenidToken())) + } catch { + MXLog.error("Failed requesting OpenID token with error: \(error)") + return .failure(.sdkError(error)) + } + } + + func homeserverCredentials() -> Result { + do { + let session = try client.session() + guard let homeserverURL = URL(string: session.homeserverUrl) else { + return .failure(.invalidServerName) + } + return .success(.init(homeserverURL: homeserverURL, accessToken: session.accessToken)) + } catch { + MXLog.error("Failed retrieving the session's credentials with error: \(error)") + return .failure(.sdkError(error)) + } + } + func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol { RoomDirectorySearchProxy(roomDirectorySearch: client.roomDirectorySearch()) } diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index e79792da5..a2501e55e 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -261,6 +261,14 @@ protocol ClientProxyProtocol: AnyObject { func profile(for userID: String) async -> Result + /// An OpenID token for authenticating against services that trust the homeserver (MSC1961). + /// Short-lived — cache per call site. + func requestOpenIDToken() async -> Result + + /// The session's REST credentials for client-server API calls the SDK doesn't cover. + /// The access token can rotate after a refresh, so re-read for every request. + func homeserverCredentials() -> Result + func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol func resolveRoomAlias(_ alias: String) async -> Result diff --git a/ElementX/Sources/Services/Client/HomeserverCredentials.swift b/ElementX/Sources/Services/Client/HomeserverCredentials.swift new file mode 100644 index 000000000..536a4e6e4 --- /dev/null +++ b/ElementX/Sources/Services/Client/HomeserverCredentials.swift @@ -0,0 +1,21 @@ +// +// 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 Foundation + +/// The session's REST credentials for direct client-server API calls that the SDK doesn't cover. +/// The access token can rotate after a refresh, so re-read these for every request and never persist them. +nonisolated struct HomeserverCredentials: Equatable { + let homeserverURL: URL + let accessToken: String +} + +extension HomeserverCredentials: CustomStringConvertible { + var description: String { + "HomeserverCredentials(homeserverURL: \(homeserverURL), accessToken: )" + } +} diff --git a/ElementX/Sources/Services/Client/OpenIDToken.swift b/ElementX/Sources/Services/Client/OpenIDToken.swift new file mode 100644 index 000000000..aac080bc5 --- /dev/null +++ b/ElementX/Sources/Services/Client/OpenIDToken.swift @@ -0,0 +1,36 @@ +// +// 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 Foundation +import MatrixRustSDK + +/// An OpenID token that third-party services can use to verify the user's identity +/// against the homeserver (MSC1961). Short-lived — cache per call site. +nonisolated struct OpenIDToken: Equatable { + let accessToken: String + let matrixServerName: String + /// The interval after which the token expires, relative to when it was requested. + let expiresIn: TimeInterval + + init(accessToken: String, matrixServerName: String, expiresIn: TimeInterval) { + self.accessToken = accessToken + self.matrixServerName = matrixServerName + self.expiresIn = expiresIn + } + + init(rustValue: MatrixRustSDK.OpenIdToken) { + accessToken = rustValue.accessToken + matrixServerName = rustValue.matrixServerName + expiresIn = TimeInterval(rustValue.expiresInSeconds) + } +} + +extension OpenIDToken: CustomStringConvertible { + var description: String { + "OpenIDToken(matrixServerName: \(matrixServerName), accessToken: )" + } +} diff --git a/ElementX/Sources/Services/HealthChat/ExtendedProfile.swift b/ElementX/Sources/Services/HealthChat/ExtendedProfile.swift new file mode 100644 index 000000000..568a79fbb --- /dev/null +++ b/ElementX/Sources/Services/HealthChat/ExtendedProfile.swift @@ -0,0 +1,92 @@ +// +// 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 Foundation + +/// A user's profile including the HealthChat extended profile fields (MSC4133), +/// as returned by `GET /_matrix/client/v3/profile/{userId}`. +nonisolated struct ExtendedProfile: Equatable { + let displayName: String? + let avatarURL: URL? + let title: String? + let firstName: String? + let lastName: String? + let specialty: String? + let practiceName: String? + let furtherInfo: String? + let businessEmail: String? + let businessTel: String? + let street: String? + let houseNumber: String? + let postalCode: String? + let city: String? + let website: String? + + /// Whether any of the HealthChat-specific fields carry a value. + var hasContent: Bool { + [title, firstName, lastName, specialty, practiceName, furtherInfo, + businessEmail, businessTel, street, houseNumber, postalCode, city, website] + .contains { $0 != nil } + } + + /// The postal address in the same "street nr, plz city" format the web client uses. + var formattedAddress: String? { + let streetLine = [street, houseNumber].compactMap { $0 }.joined(separator: " ") + let cityLine = [postalCode, city].compactMap { $0 }.joined(separator: " ") + let lines = [streetLine, cityLine].filter { !$0.isEmpty } + return lines.isEmpty ? nil : lines.joined(separator: ", ") + } +} + +extension ExtendedProfile: Decodable { + private struct StringKey: CodingKey { + let stringValue: String + var intValue: Int? { + nil + } + + init(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + nil + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: StringKey.self) + + // Presence of the canonical key suppresses the legacy key even when its value is + // empty, so a field the user cleared isn't resurrected by stale legacy data. + // Empty and non-string values read as nil. + func value(_ canonicalKey: String, legacyKey: String? = nil) -> String? { + for key in [canonicalKey, legacyKey].compactMap({ $0 }) { + guard container.contains(StringKey(stringValue: key)) else { continue } + let raw = try? container.decode(String.self, forKey: .init(stringValue: key)) + return raw.flatMap { $0.isEmpty ? nil : $0 } + } + return nil + } + + displayName = value("displayname") + avatarURL = value("avatar_url").flatMap(URL.init(string:)) + title = value("io.element.profile.title") + firstName = value("io.element.profile.first_name") + lastName = value("io.element.profile.last_name") + specialty = value("io.element.profile.specialization") + practiceName = value("io.element.profile.practice_name") + furtherInfo = value("io.element.profile.further_info") + businessEmail = value("io.element.profile.business_email") + businessTel = value("io.element.profile.business_tel") + street = value("io.element.profile.business_street", legacyKey: "io.element.profile.street") + houseNumber = value("io.element.profile.business_street_nr", legacyKey: "io.element.profile.house_number") + postalCode = value("io.element.profile.business_plz", legacyKey: "io.element.profile.postal_code") + city = value("io.element.profile.business_city") + website = value("io.element.profile.website") + } +} diff --git a/ElementX/Sources/Services/HealthChat/ExtendedProfileService.swift b/ElementX/Sources/Services/HealthChat/ExtendedProfileService.swift new file mode 100644 index 000000000..0218b31e3 --- /dev/null +++ b/ElementX/Sources/Services/HealthChat/ExtendedProfileService.swift @@ -0,0 +1,55 @@ +// +// 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 Foundation + +/// Reads profiles straight from the homeserver's profile endpoint, which — unlike the +/// Rust SDK's `getProfile` — includes the extended profile fields (MSC4133). +final class ExtendedProfileService: ExtendedProfileServiceProtocol { + private let clientProxy: ClientProxyProtocol + private let urlSession: URLSession + + init(clientProxy: ClientProxyProtocol, urlSession: URLSession = .shared) { + self.clientProxy = clientProxy + self.urlSession = urlSession + } + + func profile(for userID: String) async -> Result { + guard case let .success(credentials) = clientProxy.homeserverCredentials() else { + return .failure(.missingCredentials) + } + + let url = credentials.homeserverURL.appending(path: "_matrix/client/v3/profile/\(userID)") + var request = URLRequest(url: url) + request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization") + + let data: Data + do { + let (responseData, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + return .failure(.networkFailure) + } + guard httpResponse.statusCode == 200 else { + MXLog.error("Failed fetching the extended profile for \(userID) with status: \(httpResponse.statusCode)") + return .failure(.httpStatus(httpResponse.statusCode)) + } + + data = responseData + } catch { + MXLog.error("Failed fetching the extended profile for \(userID) with error: \(error)") + return .failure(.networkFailure) + } + + do { + return try .success(JSONDecoder().decode(ExtendedProfile.self, from: data)) + } catch { + MXLog.error("Failed decoding the extended profile for \(userID) with error: \(error)") + return .failure(.decodingFailure) + } + } +} diff --git a/ElementX/Sources/Services/HealthChat/ExtendedProfileServiceProtocol.swift b/ElementX/Sources/Services/HealthChat/ExtendedProfileServiceProtocol.swift new file mode 100644 index 000000000..67f307b7c --- /dev/null +++ b/ElementX/Sources/Services/HealthChat/ExtendedProfileServiceProtocol.swift @@ -0,0 +1,20 @@ +// +// 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 Foundation + +enum ExtendedProfileServiceError: Error, Equatable { + case missingCredentials + case networkFailure + case decodingFailure + case httpStatus(Int) +} + +// sourcery: AutoMockable +protocol ExtendedProfileServiceProtocol { + func profile(for userID: String) async -> Result +} diff --git a/ElementX/Sources/Services/HealthChat/HealthChatAPIModels.swift b/ElementX/Sources/Services/HealthChat/HealthChatAPIModels.swift new file mode 100644 index 000000000..e7383e6ed --- /dev/null +++ b/ElementX/Sources/Services/HealthChat/HealthChatAPIModels.swift @@ -0,0 +1,134 @@ +// +// 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 Foundation + +/// A member record as returned by the HealthChat API (`MatrixUser` in the backend). +/// Every field except the user ID is optional so additive backend changes never break decoding. +nonisolated struct HealthChatMember: Identifiable, Equatable { + let userID: String + let displayName: String? + let avatarURL: URL? + let title: String? + let firstName: String? + let lastName: String? + let specialty: String? + let practiceName: String? + let businessCity: String? + let businessEmail: String? + let businessTel: String? + let furtherInfo: String? + let street: String? + let houseNumber: String? + let postalCode: String? + let website: String? + let isApproved: Bool + + var id: String { + userID + } +} + +extension HealthChatMember: Decodable { + private enum CodingKeys: String, CodingKey { + case userID = "userId" + case avatarURL = "avatarMxcUrl" + case displayName, title, firstName, lastName, specialty, practiceName + case businessCity, businessEmail, businessTel, furtherInfo + case street, houseNumber, postalCode, website, isApproved + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + func value(_ key: CodingKeys) -> String? { + let raw = try? container.decodeIfPresent(String.self, forKey: key) + return (raw ?? nil).flatMap { $0.isEmpty ? nil : $0 } + } + + userID = try container.decode(String.self, forKey: .userID) + displayName = value(.displayName) + avatarURL = value(.avatarURL).flatMap(URL.init(string:)) + title = value(.title) + firstName = value(.firstName) + lastName = value(.lastName) + specialty = value(.specialty) + practiceName = value(.practiceName) + businessCity = value(.businessCity) + businessEmail = value(.businessEmail) + businessTel = value(.businessTel) + furtherInfo = value(.furtherInfo) + street = value(.street) + houseNumber = value(.houseNumber) + postalCode = value(.postalCode) + website = value(.website) + isApproved = try container.decodeIfPresent(Bool.self, forKey: .isApproved) ?? true + } +} + +/// The response of `GET /api/usermatrix/users` (`MatrixUserSearchResult` in the backend). +nonisolated struct HealthChatMembersResponse: Equatable { + let users: [HealthChatMember] + let isApproved: Bool + /// The hint shown to accounts that haven't been approved yet. + let pendingMessage: String? +} + +extension HealthChatMembersResponse: Decodable { + private enum CodingKeys: String, CodingKey { + case users, isApproved, pendingMessage + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + users = try container.decodeIfPresent([HealthChatMember].self, forKey: .users) ?? [] + isApproved = try container.decodeIfPresent(Bool.self, forKey: .isApproved) ?? true + let message = try container.decodeIfPresent(String.self, forKey: .pendingMessage) + pendingMessage = message.flatMap { $0.isEmpty ? nil : $0 } + } +} + +/// A partial profile update for `PUT /api/usermatrix/profile` (`UpdateProfileRequest` in the backend). +/// `nil` leaves a field unchanged, an empty string clears it. +nonisolated struct HealthChatProfileUpdate: Encodable, Equatable { + var firstName: String? + var lastName: String? + var title: String? + var specialty: String? + var practiceName: String? + var businessCity: String? + var businessEmail: String? + var businessTel: String? + var furtherInfo: String? + var street: String? + var houseNumber: String? + var postalCode: String? + var website: String? +} + +/// The response of `GET /api/usermatrix/profile/completeness`. +nonisolated struct HealthChatProfileCompleteness: Equatable { + let isComplete: Bool + let missingFields: [String] + let message: String? + let hasProfilePhoto: Bool +} + +extension HealthChatProfileCompleteness: Decodable { + private enum CodingKeys: String, CodingKey { + case isComplete, missingFields, message, hasProfilePhoto + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isComplete = try container.decodeIfPresent(Bool.self, forKey: .isComplete) ?? false + missingFields = try container.decodeIfPresent([String].self, forKey: .missingFields) ?? [] + let rawMessage = try container.decodeIfPresent(String.self, forKey: .message) + message = rawMessage.flatMap { $0.isEmpty ? nil : $0 } + hasProfilePhoto = try container.decodeIfPresent(Bool.self, forKey: .hasProfilePhoto) ?? false + } +} diff --git a/ElementX/Sources/Services/HealthChat/HealthChatAPIService.swift b/ElementX/Sources/Services/HealthChat/HealthChatAPIService.swift new file mode 100644 index 000000000..1398b6c1b --- /dev/null +++ b/ElementX/Sources/Services/HealthChat/HealthChatAPIService.swift @@ -0,0 +1,140 @@ +// +// 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 Foundation + +/// The client for the HealthChat backend (member directory, own profile, invitations). +/// Authenticates with an OpenID token that the backend verifies against the homeserver. +final class HealthChatAPIService: HealthChatAPIServiceProtocol { + private enum Method: String { + case get = "GET" + case put = "PUT" + case post = "POST" + } + + private let baseURL: URL + private let tokenProvider: HealthChatOpenIDTokenProviderProtocol + private let urlSession: URLSession + + init(baseURL: URL = HealthChatSettings.apiBaseURL, + tokenProvider: HealthChatOpenIDTokenProviderProtocol, + urlSession: URLSession = .shared) { + self.baseURL = baseURL + self.tokenProvider = tokenProvider + self.urlSession = urlSession + } + + func members(searchQuery: String?) async -> Result { + var queryItems = [URLQueryItem]() + if let searchQuery, !searchQuery.isEmpty { + queryItems.append(.init(name: "search", value: searchQuery)) + } + return await request(.get, path: "api/usermatrix/users", queryItems: queryItems) + } + + func ownProfile() async -> Result { + await request(.get, path: "api/usermatrix/profile") + } + + func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result { + guard let body = try? JSONEncoder().encode(update) else { + return .failure(.decodingFailure) + } + return await send(.put, path: "api/usermatrix/profile", body: body).map { _ in } + } + + func profileCompleteness() async -> Result { + await request(.get, path: "api/usermatrix/profile/completeness") + } + + func inviteMember(email: String, personalNote: String) async -> Result { + struct InvitationRequest: Encodable { + let email: String + let personalNote: String + } + + guard let body = try? JSONEncoder().encode(InvitationRequest(email: email, personalNote: personalNote)) else { + return .failure(.decodingFailure) + } + return await send(.post, path: "api/usermatrix/invite", body: body).map { _ in } + } + + // MARK: - Private + + private func request(_ method: Method, + path: String, + queryItems: [URLQueryItem] = [], + body: Data? = nil) async -> Result { + switch await send(method, path: path, queryItems: queryItems, body: body) { + case .success(let data): + do { + return try .success(JSONDecoder().decode(Response.self, from: data)) + } catch { + MXLog.error("Failed decoding the HealthChat API response from \(path) with error: \(error)") + return .failure(.decodingFailure) + } + case .failure(let error): + return .failure(error) + } + } + + private func send(_ method: Method, + path: String, + queryItems: [URLQueryItem] = [], + body: Data? = nil) async -> Result { + for attempt in 0...1 { + let token: String + switch await tokenProvider.validToken() { + case .success(let validToken): + token = validToken + case .failure(let error): + return .failure(error) + } + + var url = baseURL.appending(path: path) + if !queryItems.isEmpty { + url.append(queryItems: queryItems) + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + if let body { + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await urlSession.data(for: request) + } catch { + MXLog.error("The HealthChat API request to \(path) failed with error: \(error)") + return .failure(.networkFailure) + } + + guard let httpResponse = response as? HTTPURLResponse else { + return .failure(.networkFailure) + } + + switch httpResponse.statusCode { + case 200...299: + return .success(data) + case 401 where attempt == 0: + // The cached OpenID token may have expired server-side — retry once with a fresh one. + tokenProvider.invalidateToken() + case 401: + return .failure(.unauthorized) + default: + MXLog.error("The HealthChat API request to \(path) failed with status: \(httpResponse.statusCode)") + return .failure(.httpStatus(httpResponse.statusCode)) + } + } + + return .failure(.unauthorized) + } +} diff --git a/ElementX/Sources/Services/HealthChat/HealthChatAPIServiceProtocol.swift b/ElementX/Sources/Services/HealthChat/HealthChatAPIServiceProtocol.swift new file mode 100644 index 000000000..00ed0bac6 --- /dev/null +++ b/ElementX/Sources/Services/HealthChat/HealthChatAPIServiceProtocol.swift @@ -0,0 +1,29 @@ +// +// 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 Foundation + +enum HealthChatAPIError: Error, Equatable { + case tokenAcquisitionFailed + case unauthorized + case networkFailure + case decodingFailure + case httpStatus(Int) +} + +// sourcery: AutoMockable +protocol HealthChatAPIServiceProtocol { + func members(searchQuery: String?) async -> Result + + func ownProfile() async -> Result + + func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result + + func profileCompleteness() async -> Result + + func inviteMember(email: String, personalNote: String) async -> Result +} diff --git a/ElementX/Sources/Services/HealthChat/HealthChatOpenIDTokenProvider.swift b/ElementX/Sources/Services/HealthChat/HealthChatOpenIDTokenProvider.swift new file mode 100644 index 000000000..754e49e63 --- /dev/null +++ b/ElementX/Sources/Services/HealthChat/HealthChatOpenIDTokenProvider.swift @@ -0,0 +1,50 @@ +// +// 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 Foundation + +// sourcery: AutoMockable +protocol HealthChatOpenIDTokenProviderProtocol { + /// A valid OpenID access token, from cache when possible. + func validToken() async -> Result + + /// Drops the cached token, e.g. after the API rejected it. + func invalidateToken() +} + +final class HealthChatOpenIDTokenProvider: HealthChatOpenIDTokenProviderProtocol { + private let clientProxy: ClientProxyProtocol + private let dateProvider: () -> Date + + /// Tokens this close to expiry are refreshed early, matching the web client's buffer. + private static let expiryBuffer: TimeInterval = 60 + + private var cachedToken: (token: String, expiry: Date)? + + init(clientProxy: ClientProxyProtocol, dateProvider: @escaping () -> Date = Date.init) { + self.clientProxy = clientProxy + self.dateProvider = dateProvider + } + + func validToken() async -> Result { + if let cachedToken, dateProvider().addingTimeInterval(Self.expiryBuffer) < cachedToken.expiry { + return .success(cachedToken.token) + } + + switch await clientProxy.requestOpenIDToken() { + case .success(let token): + cachedToken = (token.accessToken, dateProvider().addingTimeInterval(token.expiresIn)) + return .success(token.accessToken) + case .failure: + return .failure(.tokenAcquisitionFailed) + } + } + + func invalidateToken() { + cachedToken = nil + } +} diff --git a/UnitTests/Sources/HealthChat/ExtendedProfileServiceTests.swift b/UnitTests/Sources/HealthChat/ExtendedProfileServiceTests.swift new file mode 100644 index 000000000..6128923f0 --- /dev/null +++ b/UnitTests/Sources/HealthChat/ExtendedProfileServiceTests.swift @@ -0,0 +1,117 @@ +// +// 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. +// + +@testable import ElementX +import Foundation +import Synchronization +import Testing + +final class ExtendedProfileServiceTests { + private let clientProxy = ClientProxyMock(.init()) + private let service: ExtendedProfileService + + init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockProfileURLProtocol.self] + service = ExtendedProfileService(clientProxy: clientProxy, + urlSession: URLSession(configuration: configuration)) + + clientProxy.homeserverCredentialsReturnValue = .success(.init(homeserverURL: "https://matrix.example.com", + accessToken: MockProfileURLProtocol.accessToken)) + } + + @Test + func fetchesProfileWithBearerToken() async throws { + let result = await service.profile(for: "@alice:healthchat.ch") + + let profile = try result.get() + #expect(profile.displayName == "Dr. Alice Alpha") + #expect(profile.businessTel == "+41 44 123 45 67") + + let request = try #require(MockProfileURLProtocol.lastRequest) + #expect(request.url?.path(percentEncoded: false) == "/_matrix/client/v3/profile/@alice:healthchat.ch") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockProfileURLProtocol.accessToken)") + } + + @Test + func missingCredentialsFailWithoutRequest() async { + clientProxy.homeserverCredentialsReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) + + let result = await service.profile(for: "@alice:healthchat.ch") + + #expect(result == .failure(.missingCredentials)) + } + + @Test + func unknownUserFailsWithHTTPStatus() async { + let result = await service.profile(for: "@unknown:healthchat.ch") + + #expect(result == .failure(.httpStatus(404))) + } + + @Test + func credentialsDescriptionRedactsAccessToken() { + let credentials = HomeserverCredentials(homeserverURL: "https://matrix.example.com", + accessToken: "supersecret") + + #expect(!String(describing: credentials).contains("supersecret")) + } +} + +// MARK: - MockProfileURLProtocol + +private nonisolated class MockProfileURLProtocol: URLProtocol { + static let accessToken = "testAccessToken" + + private static let _lastRequest: Mutex = .init(nil) + static var lastRequest: URLRequest? { + _lastRequest.withLock { $0 } + } + + private static let profileJSON = """ + { + "displayname": "Dr. Alice Alpha", + "io.element.profile.business_tel": "+41 44 123 45 67" + } + """ + + private static let responses: [String: (Int, Data)] = [ + "/_matrix/client/v3/profile/@alice:healthchat.ch": (200, Data(profileJSON.utf8)) + ] + + override func startLoading() { + MockProfileURLProtocol._lastRequest.withLock { $0 = request } + + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + let isAuthenticated = request.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockProfileURLProtocol.accessToken)" + let path = url.path(percentEncoded: false) + let (statusCode, data) = isAuthenticated ? MockProfileURLProtocol.responses[path] ?? (404, Data()) : (401, Data()) + + guard let response = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil) else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } +} diff --git a/UnitTests/Sources/HealthChat/ExtendedProfileTests.swift b/UnitTests/Sources/HealthChat/ExtendedProfileTests.swift new file mode 100644 index 000000000..2e6f404db --- /dev/null +++ b/UnitTests/Sources/HealthChat/ExtendedProfileTests.swift @@ -0,0 +1,149 @@ +// +// 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. +// + +@testable import ElementX +import Foundation +import Testing + +final class ExtendedProfileTests { + @Test + func decodesFullProfile() throws { + let json = """ + { + "displayname": "Dr. Alice Alpha", + "avatar_url": "mxc://healthchat.ch/abc123", + "io.element.profile.title": "Dr. med.", + "io.element.profile.first_name": "Alice", + "io.element.profile.last_name": "Alpha", + "io.element.profile.specialization": "Physiotherapie", + "io.element.profile.practice_name": "Praxis Alpha", + "io.element.profile.further_info": "Multi\\nline", + "io.element.profile.business_email": "praxis@example.ch", + "io.element.profile.business_tel": "+41 44 123 45 67", + "io.element.profile.business_street": "Friedaustrasse", + "io.element.profile.business_street_nr": "17", + "io.element.profile.business_plz": "8003", + "io.element.profile.business_city": "Zürich", + "io.element.profile.website": "https://example.ch" + } + """ + + let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8)) + + #expect(profile.displayName == "Dr. Alice Alpha") + #expect(profile.avatarURL == URL(string: "mxc://healthchat.ch/abc123")) + #expect(profile.title == "Dr. med.") + #expect(profile.firstName == "Alice") + #expect(profile.lastName == "Alpha") + #expect(profile.specialty == "Physiotherapie") + #expect(profile.practiceName == "Praxis Alpha") + #expect(profile.furtherInfo == "Multi\nline") + #expect(profile.businessEmail == "praxis@example.ch") + #expect(profile.businessTel == "+41 44 123 45 67") + #expect(profile.street == "Friedaustrasse") + #expect(profile.houseNumber == "17") + #expect(profile.postalCode == "8003") + #expect(profile.city == "Zürich") + #expect(profile.website == "https://example.ch") + #expect(profile.hasContent) + } + + @Test + func decodesLegacyAddressKeysAsFallback() throws { + let json = """ + { + "io.element.profile.street": "Altweg", + "io.element.profile.house_number": "3", + "io.element.profile.postal_code": "9000" + } + """ + + let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8)) + + #expect(profile.street == "Altweg") + #expect(profile.houseNumber == "3") + #expect(profile.postalCode == "9000") + } + + @Test + func canonicalKeyPresenceWinsOverLegacyEvenWhenEmpty() throws { + let json = """ + { + "io.element.profile.business_street": "", + "io.element.profile.street": "Altweg" + } + """ + + let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8)) + + #expect(profile.street == nil) + } + + @Test + func emptyAndNonStringValuesBecomeNil() throws { + let json = """ + { + "displayname": "Alice", + "io.element.profile.title": "", + "io.element.profile.specialization": 5, + "us.cloke.msc4175.tz": "Europe/Zurich" + } + """ + + let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8)) + + #expect(profile.title == nil) + #expect(profile.specialty == nil) + #expect(!profile.hasContent) + } + + @Test + func hasContentWithSingleField() throws { + let json = """ + { "io.element.profile.business_tel": "+41 44 123 45 67" } + """ + + let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8)) + + #expect(profile.hasContent) + } + + @Test + func formattedAddressWithAllComponents() throws { + let json = """ + { + "io.element.profile.business_street": "Friedaustrasse", + "io.element.profile.business_street_nr": "17", + "io.element.profile.business_plz": "8003", + "io.element.profile.business_city": "Zürich" + } + """ + + let profile = try JSONDecoder().decode(ExtendedProfile.self, from: Data(json.utf8)) + + #expect(profile.formattedAddress == "Friedaustrasse 17, 8003 Zürich") + } + + @Test + func formattedAddressWithPartialComponents() throws { + let cityOnly = try JSONDecoder().decode(ExtendedProfile.self, from: Data(""" + { + "io.element.profile.business_plz": "8003", + "io.element.profile.business_city": "Zürich" + } + """.utf8)) + #expect(cityOnly.formattedAddress == "8003 Zürich") + + let streetOnly = try JSONDecoder().decode(ExtendedProfile.self, from: Data(""" + { "io.element.profile.business_street": "Friedaustrasse" } + """.utf8)) + #expect(streetOnly.formattedAddress == "Friedaustrasse") + + let empty = try JSONDecoder().decode(ExtendedProfile.self, from: Data("{}".utf8)) + #expect(empty.formattedAddress == nil) + } +} diff --git a/UnitTests/Sources/HealthChat/HealthChatAPIModelsTests.swift b/UnitTests/Sources/HealthChat/HealthChatAPIModelsTests.swift new file mode 100644 index 000000000..12dc54c64 --- /dev/null +++ b/UnitTests/Sources/HealthChat/HealthChatAPIModelsTests.swift @@ -0,0 +1,173 @@ +// +// 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. +// + +@testable import ElementX +import Foundation +import Testing + +final class HealthChatAPIModelsTests { + @Test + func decodesMemberFromFullJSON() throws { + let json = """ + { + "userId": "@alice:healthchat.ch", + "displayName": "Dr. Alice Alpha", + "avatarMxcUrl": "mxc://healthchat.ch/abc123", + "title": "Dr. med.", + "firstName": "Alice", + "lastName": "Alpha", + "specialty": "Physiotherapie", + "practiceName": "Praxis Alpha", + "businessCity": "Zürich", + "businessEmail": "praxis@example.ch", + "businessTel": "+41 44 123 45 67", + "furtherInfo": "Info", + "street": "Friedaustrasse", + "houseNumber": "17", + "postalCode": "8003", + "website": "https://example.ch", + "isApproved": true, + "someFutureField": { "nested": true } + } + """ + + let member = try JSONDecoder().decode(HealthChatMember.self, from: Data(json.utf8)) + + #expect(member.userID == "@alice:healthchat.ch") + #expect(member.id == "@alice:healthchat.ch") + #expect(member.displayName == "Dr. Alice Alpha") + #expect(member.avatarURL == URL(string: "mxc://healthchat.ch/abc123")) + #expect(member.title == "Dr. med.") + #expect(member.firstName == "Alice") + #expect(member.lastName == "Alpha") + #expect(member.specialty == "Physiotherapie") + #expect(member.practiceName == "Praxis Alpha") + #expect(member.businessCity == "Zürich") + #expect(member.businessEmail == "praxis@example.ch") + #expect(member.businessTel == "+41 44 123 45 67") + #expect(member.furtherInfo == "Info") + #expect(member.street == "Friedaustrasse") + #expect(member.houseNumber == "17") + #expect(member.postalCode == "8003") + #expect(member.website == "https://example.ch") + #expect(member.isApproved) + } + + @Test + func decodesMinimalMemberWithDefaults() throws { + let json = """ + { "userId": "@bob:healthchat.ch" } + """ + + let member = try JSONDecoder().decode(HealthChatMember.self, from: Data(json.utf8)) + + #expect(member.userID == "@bob:healthchat.ch") + #expect(member.displayName == nil) + #expect(member.avatarURL == nil) + #expect(member.isApproved) + } + + @Test + func emptyStringsBecomeNil() throws { + let json = """ + { + "userId": "@bob:healthchat.ch", + "displayName": "", + "avatarMxcUrl": "", + "businessTel": "" + } + """ + + let member = try JSONDecoder().decode(HealthChatMember.self, from: Data(json.utf8)) + + #expect(member.displayName == nil) + #expect(member.avatarURL == nil) + #expect(member.businessTel == nil) + } + + @Test + func decodesMembersResponse() throws { + let json = """ + { + "users": [{ "userId": "@alice:healthchat.ch" }], + "totalCount": 1, + "searchQuery": "", + "isApproved": true, + "pendingMessage": null + } + """ + + let response = try JSONDecoder().decode(HealthChatMembersResponse.self, from: Data(json.utf8)) + + #expect(response.users.count == 1) + #expect(response.users.first?.userID == "@alice:healthchat.ch") + #expect(response.isApproved) + #expect(response.pendingMessage == nil) + } + + @Test + func decodesPendingMembersResponse() throws { + let json = """ + { + "users": [], + "isApproved": false, + "pendingMessage": "Ihr Konto wird geprüft" + } + """ + + let response = try JSONDecoder().decode(HealthChatMembersResponse.self, from: Data(json.utf8)) + + #expect(response.users.isEmpty) + #expect(!response.isApproved) + #expect(response.pendingMessage == "Ihr Konto wird geprüft") + } + + @Test + func profileUpdateEncodesOnlySetFields() throws { + var update = HealthChatProfileUpdate() + update.businessTel = "+41 44 123 45 67" + update.specialty = "Physiotherapie" + update.furtherInfo = "" + + let data = try JSONEncoder().encode(update) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(object.keys.sorted() == ["businessTel", "furtherInfo", "specialty"]) + #expect(object["businessTel"] as? String == "+41 44 123 45 67") + #expect(object["specialty"] as? String == "Physiotherapie") + #expect(object["furtherInfo"] as? String == "") + } + + @Test + func decodesProfileCompleteness() throws { + let json = """ + { + "isComplete": false, + "missingFields": ["Fachgebiet", "Ort"], + "message": "Profil unvollständig", + "hasProfilePhoto": true + } + """ + + let completeness = try JSONDecoder().decode(HealthChatProfileCompleteness.self, from: Data(json.utf8)) + + #expect(!completeness.isComplete) + #expect(completeness.missingFields == ["Fachgebiet", "Ort"]) + #expect(completeness.message == "Profil unvollständig") + #expect(completeness.hasProfilePhoto) + } + + @Test + func decodesProfileCompletenessWithDefaults() throws { + let completeness = try JSONDecoder().decode(HealthChatProfileCompleteness.self, from: Data("{}".utf8)) + + #expect(!completeness.isComplete) + #expect(completeness.missingFields.isEmpty) + #expect(completeness.message == nil) + #expect(!completeness.hasProfilePhoto) + } +} diff --git a/UnitTests/Sources/HealthChat/HealthChatAPIServiceTests.swift b/UnitTests/Sources/HealthChat/HealthChatAPIServiceTests.swift new file mode 100644 index 000000000..b2a753885 --- /dev/null +++ b/UnitTests/Sources/HealthChat/HealthChatAPIServiceTests.swift @@ -0,0 +1,251 @@ +// +// 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. +// + +@testable import ElementX +import Foundation +import Synchronization +import Testing + +final class HealthChatAPIServiceTests { + private let tokenProvider = HealthChatOpenIDTokenProviderMock() + private let service: HealthChatAPIService + + init() { + MockAPIURLProtocol.reset() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockAPIURLProtocol.self] + service = HealthChatAPIService(baseURL: "https://api.test", + tokenProvider: tokenProvider, + urlSession: URLSession(configuration: configuration)) + + tokenProvider.validTokenReturnValue = .success(MockAPIURLProtocol.validToken) + } + + @Test + func membersRequestsCorrectURL() async throws { + let result = await service.members(searchQuery: nil) + + let response = try result.get() + #expect(response.users.map(\.userID) == ["@alice:healthchat.ch"]) + #expect(response.isApproved) + + let request = try #require(MockAPIURLProtocol.lastRequest) + #expect(request.httpMethod == "GET") + #expect(request.url?.path() == "/api/usermatrix/users") + #expect(request.url?.query() == nil) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockAPIURLProtocol.validToken)") + } + + @Test + func membersAppendsSearchQuery() async throws { + _ = await service.members(searchQuery: "Zürich") + + let url = try #require(MockAPIURLProtocol.lastRequest?.url) + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + #expect(queryItems == [URLQueryItem(name: "search", value: "Zürich")]) + } + + @Test + func ownProfileRequestsCorrectURL() async throws { + let result = await service.ownProfile() + + let member = try result.get() + #expect(member.userID == "@me:healthchat.ch") + + let request = try #require(MockAPIURLProtocol.lastRequest) + #expect(request.httpMethod == "GET") + #expect(request.url?.path() == "/api/usermatrix/profile") + } + + @Test + func updateOwnProfileSendsPartialJSONBody() async throws { + var update = HealthChatProfileUpdate() + update.businessTel = "+41 44 123 45 67" + update.specialty = "Physiotherapie" + + let result = await service.updateOwnProfile(update) + + try result.get() + + let request = try #require(MockAPIURLProtocol.lastRequest) + #expect(request.httpMethod == "PUT") + #expect(request.url?.path() == "/api/usermatrix/profile") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + + let body = try #require(MockAPIURLProtocol.lastRequestBody) + let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(object.keys.sorted() == ["businessTel", "specialty"]) + } + + @Test + func profileCompletenessRequestsCorrectURL() async throws { + let result = await service.profileCompleteness() + + let completeness = try result.get() + #expect(!completeness.isComplete) + + #expect(MockAPIURLProtocol.lastRequest?.url?.path() == "/api/usermatrix/profile/completeness") + } + + @Test + func inviteMemberSendsEmailAndPersonalNote() async throws { + let result = await service.inviteMember(email: "new@example.ch", personalNote: "Bitte tritt uns bei!") + + try result.get() + + let request = try #require(MockAPIURLProtocol.lastRequest) + #expect(request.httpMethod == "POST") + #expect(request.url?.path() == "/api/usermatrix/invite") + + let body = try #require(MockAPIURLProtocol.lastRequestBody) + let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(object["email"] as? String == "new@example.ch") + #expect(object["personalNote"] as? String == "Bitte tritt uns bei!") + } + + @Test + func retriesOnceWithFreshTokenAfter401() async { + let tokenRequests = Mutex(0) + tokenProvider.validTokenClosure = { + let count = tokenRequests.withLock { count in + count += 1 + return count + } + return .success(count == 1 ? "staleToken" : MockAPIURLProtocol.validToken) + } + + let result = await service.members(searchQuery: nil) + + #expect((try? result.get()) != nil) + #expect(tokenProvider.invalidateTokenCallsCount == 1) + #expect(tokenRequests.withLock { $0 } == 2) + } + + @Test + func persistent401FailsAsUnauthorized() async { + tokenProvider.validTokenReturnValue = .success("staleToken") + + let result = await service.members(searchQuery: nil) + + #expect(result == .failure(.unauthorized)) + #expect(tokenProvider.invalidateTokenCallsCount == 1) + } + + @Test + func tokenFailurePropagatesWithoutRequest() async { + tokenProvider.validTokenReturnValue = .failure(.tokenAcquisitionFailed) + + let result = await service.members(searchQuery: nil) + + #expect(result == .failure(.tokenAcquisitionFailed)) + #expect(MockAPIURLProtocol.lastRequest == nil) + } +} + +// MARK: - MockAPIURLProtocol + +private nonisolated class MockAPIURLProtocol: URLProtocol { + static let validToken = "freshOpenIDToken" + + private struct Captured { + var request: URLRequest? + var body: Data? + } + + private static let captured: Mutex = .init(.init()) + + static var lastRequest: URLRequest? { + captured.withLock { $0.request } + } + + static var lastRequestBody: Data? { + captured.withLock { $0.body } + } + + static func reset() { + captured.withLock { $0 = .init() } + } + + private static let membersJSON = """ + { + "users": [{ "userId": "@alice:healthchat.ch" }], + "isApproved": true + } + """ + + private static let profileJSON = """ + { "userId": "@me:healthchat.ch" } + """ + + private static let responses: [String: (Int, Data)] = [ + "/api/usermatrix/users": (200, Data(membersJSON.utf8)), + "/api/usermatrix/profile": (200, Data(profileJSON.utf8)), + "/api/usermatrix/profile/completeness": (200, Data(#"{ "isComplete": false, "missingFields": [], "message": "", "hasProfilePhoto": false }"#.utf8)), + "/api/usermatrix/invite": (200, Data(#"{ "message": "ok" }"#.utf8)) + ] + + override func startLoading() { + let body = Self.bodyData(of: request) + MockAPIURLProtocol.captured.withLock { + $0.request = request + $0.body = body + } + + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + let isAuthenticated = request.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockAPIURLProtocol.validToken)" + let (statusCode, data) = isAuthenticated ? MockAPIURLProtocol.responses[url.path()] ?? (404, Data()) : (401, Data()) + + guard let response = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil) else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + /// `URLProtocol` never sees `httpBody` for data tasks — the body only arrives as a stream. + private static func bodyData(of request: URLRequest) -> Data? { + if let body = request.httpBody { + return body + } + guard let stream = request.httpBodyStream else { + return nil + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + guard read > 0 else { break } + data.append(buffer, count: read) + } + return data + } +} diff --git a/UnitTests/Sources/HealthChat/HealthChatOpenIDTokenProviderTests.swift b/UnitTests/Sources/HealthChat/HealthChatOpenIDTokenProviderTests.swift new file mode 100644 index 000000000..1be00660b --- /dev/null +++ b/UnitTests/Sources/HealthChat/HealthChatOpenIDTokenProviderTests.swift @@ -0,0 +1,86 @@ +// +// 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. +// + +@testable import ElementX +import Foundation +import Testing + +final class HealthChatOpenIDTokenProviderTests { + private let clientProxy = ClientProxyMock(.init()) + + @Test + func fetchesTokenOnFirstCall() async { + clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenA", + matrixServerName: "healthchat.ch", + expiresIn: 3600)) + let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy) + + let result = await provider.validToken() + + #expect(result == .success("tokenA")) + #expect(clientProxy.requestOpenIDTokenCallsCount == 1) + } + + @Test + func cachesTokenUntilExpiry() async { + clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenA", + matrixServerName: "healthchat.ch", + expiresIn: 3600)) + let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy) + + _ = await provider.validToken() + let result = await provider.validToken() + + #expect(result == .success("tokenA")) + #expect(clientProxy.requestOpenIDTokenCallsCount == 1) + } + + @Test + func refreshesTokenWithinExpiryBuffer() async { + clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenA", + matrixServerName: "healthchat.ch", + expiresIn: 120)) + var now = Date(timeIntervalSince1970: 1_000_000) + let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy) { now } + + _ = await provider.validToken() + + // 59s of validity left is inside the 60s buffer and must trigger a refresh. + now = now.addingTimeInterval(61) + clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenB", + matrixServerName: "healthchat.ch", + expiresIn: 120)) + let result = await provider.validToken() + + #expect(result == .success("tokenB")) + #expect(clientProxy.requestOpenIDTokenCallsCount == 2) + } + + @Test + func invalidateForcesRefetch() async { + clientProxy.requestOpenIDTokenReturnValue = .success(.init(accessToken: "tokenA", + matrixServerName: "healthchat.ch", + expiresIn: 3600)) + let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy) + + _ = await provider.validToken() + provider.invalidateToken() + _ = await provider.validToken() + + #expect(clientProxy.requestOpenIDTokenCallsCount == 2) + } + + @Test + func failedRequestReturnsTokenAcquisitionFailure() async { + clientProxy.requestOpenIDTokenReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) + let provider = HealthChatOpenIDTokenProvider(clientProxy: clientProxy) + + let result = await provider.validToken() + + #expect(result == .failure(.tokenAcquisitionFailed)) + } +}