mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
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
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsFlowCoordinator.swift; sourceTree = "<group>"; };
|
||||
087194751572DF7CA65D1CCB /* HealthChatAPIServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIServiceTests.swift; sourceTree = "<group>"; };
|
||||
094F6B21835890B470DF540C /* SpaceScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = "<group>"; };
|
||||
0A2074C0449B83D5858BD2D7 /* FrequentlyUsedEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequentlyUsedEmoji.swift; sourceTree = "<group>"; };
|
||||
@@ -1852,6 +1868,7 @@
|
||||
113356152C099951A6D17D85 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
113873B97F27394ABE41BCFD /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
118892B93A8CFDD691D9B5E3 /* RoomLiveLocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomLiveLocationService.swift; sourceTree = "<group>"; };
|
||||
11E3983406311A8BBE37A19A /* HealthChatOpenIDTokenProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatOpenIDTokenProviderTests.swift; sourceTree = "<group>"; };
|
||||
11FCAE847556719BBE7A0882 /* ReportRoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreenModels.swift; sourceTree = "<group>"; };
|
||||
1215A4FC53D2319E81AE8970 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
1222DB76B917EB8A55365BA5 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
|
||||
@@ -1888,6 +1905,7 @@
|
||||
176A581FDC61737676782607 /* ClientFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientFactoryProtocol.swift; sourceTree = "<group>"; };
|
||||
17A8AA0DFA06012A9DAB951E /* TimelineProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProxyMock.swift; sourceTree = "<group>"; };
|
||||
17BAE25A0E9E9F2F1BBA8930 /* DeactivateAccountScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeactivateAccountScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
180F8DC392F9D8DA27AAF3E5 /* ExtendedProfileServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfileServiceProtocol.swift; sourceTree = "<group>"; };
|
||||
181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePreferenceTests.swift; sourceTree = "<group>"; };
|
||||
18486B87745B1811E7FBD3D2 /* AnalyticsPromptScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenModels.swift; sourceTree = "<group>"; };
|
||||
184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorationTimelineItemProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -1959,6 +1977,7 @@
|
||||
21DD8599815136EFF5B73F38 /* UserFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFlowTests.swift; sourceTree = "<group>"; };
|
||||
226A56746AD99ABDA97189E1 /* SpaceServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceServiceProxy.swift; sourceTree = "<group>"; };
|
||||
22730A30C50AC2E3D5BA8642 /* InviteUsersScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
227A208A5E4E72117632EB01 /* HomeserverCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeserverCredentials.swift; sourceTree = "<group>"; };
|
||||
227AC5D71A4CE43512062243 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
|
||||
22B384D54464FA39C6C7F6E7 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ca; path = ca.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
233D5F7E5E9F49ABF3413291 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = hr; path = hr.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
@@ -2314,11 +2333,13 @@
|
||||
5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = "<group>"; };
|
||||
5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = "<group>"; };
|
||||
5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
5EF73F8BBCB34CC51C31995D /* ExtendedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfile.swift; sourceTree = "<group>"; };
|
||||
5EFB1D29B0870AFB6A56E9B8 /* IdentityResetHandleSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityResetHandleSDKMock.swift; sourceTree = "<group>"; };
|
||||
5F12E996BFBEB43815189ABF /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionProtocol.swift; sourceTree = "<group>"; };
|
||||
5FACD034DB52525A3CEF2BDF /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = "<group>"; };
|
||||
60011EF0086E49DBD78E16E5 /* ResolveVerifiedUserSendFailureScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenModels.swift; sourceTree = "<group>"; };
|
||||
6004777518436121012B76CF /* HealthChatAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIService.swift; sourceTree = "<group>"; };
|
||||
6033779EB37259F27F938937 /* ClientProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxyProtocol.swift; sourceTree = "<group>"; };
|
||||
604A69C081B935D6A38DE6D8 /* UserProfileScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileScreenModels.swift; sourceTree = "<group>"; };
|
||||
60C9BAE9F9436B14E4E22E8F /* PinnedItemsBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedItemsBannerView.swift; sourceTree = "<group>"; };
|
||||
@@ -2406,6 +2427,7 @@
|
||||
70C86696AC9521F8ED88FBEB /* MediaUploadPreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreen.swift; sourceTree = "<group>"; };
|
||||
70F8DAEF1A8131BDFD4CDE83 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
713B48DBF65DE4B0DD445D66 /* ReportContentScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
715B60D68D20808E328DA1DA /* HealthChatSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatSettings.swift; sourceTree = "<group>"; };
|
||||
7168FDF4B2B394142FEE2A76 /* PresenceServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceServiceTests.swift; sourceTree = "<group>"; };
|
||||
718D8767035D37E2DB5CC550 /* QRCodeLoginScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
7199693797B66245EF97BCF5 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
@@ -2455,6 +2477,7 @@
|
||||
79351405CD99312A182B2488 /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = "<group>"; };
|
||||
796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
79A106D76741914F82664959 /* StateStoreViewModelV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateStoreViewModelV2.swift; sourceTree = "<group>"; };
|
||||
79AFBE494F9CCAAE8046203B /* ExtendedProfileServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfileServiceTests.swift; sourceTree = "<group>"; };
|
||||
7A03E073077D92AA19C43DCF /* IdentityConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
7A60C2712F5607406E8B1C80 /* HealthChatBrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatBrandColors.swift; sourceTree = "<group>"; };
|
||||
7A6D867A7FBB70C6EFDBCBC5 /* AccessibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityTests.swift; sourceTree = "<group>"; };
|
||||
@@ -2569,6 +2592,7 @@
|
||||
8A1F2AAA3F0F2B72D2FFE4D0 /* MapTilerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerConfiguration.swift; sourceTree = "<group>"; };
|
||||
8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
8AB4E616E6405AA5DD5A04EA /* ExtendedProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfileTests.swift; sourceTree = "<group>"; };
|
||||
8AE0C9653870803E4F91F474 /* RoomListFiltersStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFiltersStateTests.swift; sourceTree = "<group>"; };
|
||||
8AE78FA0011E07920AE83135 /* PlainMentionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainMentionBuilder.swift; sourceTree = "<group>"; };
|
||||
8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoader.swift; sourceTree = "<group>"; };
|
||||
@@ -2649,6 +2673,7 @@
|
||||
98ABC939BC8F08CA3E967D6C /* JoinCallButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinCallButton.swift; sourceTree = "<group>"; };
|
||||
98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemThreadSummary.swift; sourceTree = "<group>"; };
|
||||
98F4DFFFC62187D9A4D2030D /* ClassicAppAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppAccountManager.swift; sourceTree = "<group>"; };
|
||||
994DECEF3C67C4A715B09C82 /* HealthChatAPIServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIServiceProtocol.swift; sourceTree = "<group>"; };
|
||||
996AF8CF3FCF19B62F005C57 /* ClientFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientFactory.swift; sourceTree = "<group>"; };
|
||||
997BF045585AF6DB2EBC5755 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
99EB08B2C8B01F3BFE4B0CEC /* RoomMemberDetailsScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenHook.swift; sourceTree = "<group>"; };
|
||||
@@ -2706,6 +2731,7 @@
|
||||
A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryPicker.swift; sourceTree = "<group>"; };
|
||||
A243A6E6207297123E60DE48 /* TimelineItemMacContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMacContextMenu.swift; sourceTree = "<group>"; };
|
||||
A2723A4AF3AB9F5E18D26E49 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
A2B7938C8D756559003D4AB4 /* OpenIDToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenIDToken.swift; sourceTree = "<group>"; };
|
||||
A3A4F56855188DAD23E59031 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
A3B4B58B79A6FA250B24A1EC /* HomeScreenContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenContent.swift; sourceTree = "<group>"; };
|
||||
A3E3DE6616DCA81AD5F4791B /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
@@ -2951,6 +2977,7 @@
|
||||
CA90BD288E5AE6BC643AFDDF /* TemplateScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
CACA846B3E3E9A521D98B178 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CB04B2D794885025DACFCEFB /* SnapshotableGlassEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotableGlassEffect.swift; sourceTree = "<group>"; };
|
||||
CB71F86BB7A3AC9B32845B5B /* HealthChatAPIModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIModelsTests.swift; sourceTree = "<group>"; };
|
||||
CB7B588A06911B455AC0B4C9 /* ManageRoomMemberSheetViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageRoomMemberSheetViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
CB98BFD8E93C7FCCEDEC46F9 /* SpacesScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacesScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
CBBCC6E74774E79B599625D0 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -2970,6 +2997,7 @@
|
||||
CDA38C9B58693A49A3032552 /* DeclineAndBlockScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = "<group>"; };
|
||||
CDE3F3911FF7CC639BDE5844 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CE02F1DAF9054BD6F960D893 /* HealthChatAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAPIModels.swift; sourceTree = "<group>"; };
|
||||
CEE20623EB4A9B88FB29F2BA /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/SAS.strings; sourceTree = "<group>"; };
|
||||
CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = "<group>"; };
|
||||
CF19027E7FFA5E63D148873A /* CreateRoomScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
@@ -3033,6 +3061,7 @@
|
||||
D8F5F9E02B1AB5350B1815E7 /* TimelineStartRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
D8FC33C3F6BF597E095CE9FA /* HomeScreenInviteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenInviteCell.swift; sourceTree = "<group>"; };
|
||||
D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecorderTests.swift; sourceTree = "<group>"; };
|
||||
D94248E57F753281A220BBCC /* HealthChatOpenIDTokenProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatOpenIDTokenProvider.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreen.swift; sourceTree = "<group>"; };
|
||||
@@ -3102,6 +3131,7 @@
|
||||
E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
|
||||
E53BFB7E4F329621C844E8C3 /* AnalyticsPromptScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreen.swift; sourceTree = "<group>"; };
|
||||
E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsProxyProtocol.swift; sourceTree = "<group>"; };
|
||||
E5A1F965895E27CAEA93B58A /* ExtendedProfileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedProfileService.swift; sourceTree = "<group>"; };
|
||||
E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerProtocol.swift; sourceTree = "<group>"; };
|
||||
E5F2B6443D1ED8602F328539 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ru; path = ru.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
E5FDFAA04174CC99FB66391C /* EditRoomAddressScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditRoomAddressScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
@@ -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 = "<group>";
|
||||
@@ -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 = "<group>";
|
||||
@@ -5713,6 +5748,18 @@
|
||||
path = Messages;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
86301892A569A75EC5ADDCF2 /* HealthChat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
79AFBE494F9CCAAE8046203B /* ExtendedProfileServiceTests.swift */,
|
||||
8AB4E616E6405AA5DD5A04EA /* ExtendedProfileTests.swift */,
|
||||
CB71F86BB7A3AC9B32845B5B /* HealthChatAPIModelsTests.swift */,
|
||||
087194751572DF7CA65D1CCB /* HealthChatAPIServiceTests.swift */,
|
||||
11E3983406311A8BBE37A19A /* HealthChatOpenIDTokenProviderTests.swift */,
|
||||
);
|
||||
path = HealthChat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
864330656491EBAADA4901D3 /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -6500,6 +6547,20 @@
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<OpenIDToken, ClientProxyError>!
|
||||
var requestOpenIDTokenReturnValue: Result<OpenIDToken, ClientProxyError>! {
|
||||
get { requestOpenIDTokenReturnValueLock.withLock { requestOpenIDTokenUnderlyingReturnValue } }
|
||||
set { requestOpenIDTokenReturnValueLock.withLock { requestOpenIDTokenUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var requestOpenIDTokenClosure: (() async -> Result<OpenIDToken, ClientProxyError>)?
|
||||
|
||||
@concurrent func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError> {
|
||||
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<HomeserverCredentials, ClientProxyError>!
|
||||
var homeserverCredentialsReturnValue: Result<HomeserverCredentials, ClientProxyError>! {
|
||||
get { homeserverCredentialsReturnValueLock.withLock { homeserverCredentialsUnderlyingReturnValue } }
|
||||
set { homeserverCredentialsReturnValueLock.withLock { homeserverCredentialsUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var homeserverCredentialsClosure: (() -> Result<HomeserverCredentials, ClientProxyError>)?
|
||||
|
||||
func homeserverCredentials() -> Result<HomeserverCredentials, ClientProxyError> {
|
||||
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<ExtendedProfile, ExtendedProfileServiceError>!
|
||||
var profileForReturnValue: Result<ExtendedProfile, ExtendedProfileServiceError>! {
|
||||
get { profileForReturnValueLock.withLock { profileForUnderlyingReturnValue } }
|
||||
set { profileForReturnValueLock.withLock { profileForUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var profileForClosure: ((String) async -> Result<ExtendedProfile, ExtendedProfileServiceError>)?
|
||||
|
||||
@concurrent func profile(for userID: String) async -> Result<ExtendedProfile, ExtendedProfileServiceError> {
|
||||
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<HealthChatMembersResponse, HealthChatAPIError>!
|
||||
var membersSearchQueryReturnValue: Result<HealthChatMembersResponse, HealthChatAPIError>! {
|
||||
get { membersSearchQueryReturnValueLock.withLock { membersSearchQueryUnderlyingReturnValue } }
|
||||
set { membersSearchQueryReturnValueLock.withLock { membersSearchQueryUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var membersSearchQueryClosure: ((String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func members(searchQuery: String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError> {
|
||||
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<HealthChatMember, HealthChatAPIError>!
|
||||
var ownProfileReturnValue: Result<HealthChatMember, HealthChatAPIError>! {
|
||||
get { ownProfileReturnValueLock.withLock { ownProfileUnderlyingReturnValue } }
|
||||
set { ownProfileReturnValueLock.withLock { ownProfileUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var ownProfileClosure: (() async -> Result<HealthChatMember, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func ownProfile() async -> Result<HealthChatMember, HealthChatAPIError> {
|
||||
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<Void, HealthChatAPIError>!
|
||||
var updateOwnProfileReturnValue: Result<Void, HealthChatAPIError>! {
|
||||
get { updateOwnProfileReturnValueLock.withLock { updateOwnProfileUnderlyingReturnValue } }
|
||||
set { updateOwnProfileReturnValueLock.withLock { updateOwnProfileUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var updateOwnProfileClosure: ((HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError> {
|
||||
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<HealthChatProfileCompleteness, HealthChatAPIError>!
|
||||
var profileCompletenessReturnValue: Result<HealthChatProfileCompleteness, HealthChatAPIError>! {
|
||||
get { profileCompletenessReturnValueLock.withLock { profileCompletenessUnderlyingReturnValue } }
|
||||
set { profileCompletenessReturnValueLock.withLock { profileCompletenessUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var profileCompletenessClosure: (() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func profileCompleteness() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError> {
|
||||
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<Void, HealthChatAPIError>!
|
||||
var inviteMemberEmailPersonalNoteReturnValue: Result<Void, HealthChatAPIError>! {
|
||||
get { inviteMemberEmailPersonalNoteReturnValueLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReturnValue } }
|
||||
set { inviteMemberEmailPersonalNoteReturnValueLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var inviteMemberEmailPersonalNoteClosure: ((String, String) async -> Result<Void, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError> {
|
||||
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<String, HealthChatAPIError>!
|
||||
var validTokenReturnValue: Result<String, HealthChatAPIError>! {
|
||||
get { validTokenReturnValueLock.withLock { validTokenUnderlyingReturnValue } }
|
||||
set { validTokenReturnValueLock.withLock { validTokenUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var validTokenClosure: (() async -> Result<String, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func validToken() async -> Result<String, HealthChatAPIError> {
|
||||
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
|
||||
|
||||
@@ -871,6 +871,28 @@ class ClientProxy: ClientProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError> {
|
||||
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<HomeserverCredentials, ClientProxyError> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -261,6 +261,14 @@ protocol ClientProxyProtocol: AnyObject {
|
||||
|
||||
func profile(for userID: String) async -> Result<UserProfile, ClientProxyError>
|
||||
|
||||
/// An OpenID token for authenticating against services that trust the homeserver (MSC1961).
|
||||
/// Short-lived — cache per call site.
|
||||
func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError>
|
||||
|
||||
/// 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<HomeserverCredentials, ClientProxyError>
|
||||
|
||||
func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol
|
||||
|
||||
func resolveRoomAlias(_ alias: String) async -> Result<ResolvedRoomAlias, ClientProxyError>
|
||||
|
||||
@@ -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: <redacted>)"
|
||||
}
|
||||
}
|
||||
@@ -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: <redacted>)"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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<ExtendedProfile, ExtendedProfileServiceError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ExtendedProfile, ExtendedProfileServiceError>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<HealthChatMembersResponse, HealthChatAPIError> {
|
||||
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<HealthChatMember, HealthChatAPIError> {
|
||||
await request(.get, path: "api/usermatrix/profile")
|
||||
}
|
||||
|
||||
func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError> {
|
||||
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<HealthChatProfileCompleteness, HealthChatAPIError> {
|
||||
await request(.get, path: "api/usermatrix/profile/completeness")
|
||||
}
|
||||
|
||||
func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError> {
|
||||
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<Response: Decodable>(_ method: Method,
|
||||
path: String,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
body: Data? = nil) async -> Result<Response, HealthChatAPIError> {
|
||||
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<Data, HealthChatAPIError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<HealthChatMembersResponse, HealthChatAPIError>
|
||||
|
||||
func ownProfile() async -> Result<HealthChatMember, HealthChatAPIError>
|
||||
|
||||
func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError>
|
||||
|
||||
func profileCompleteness() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError>
|
||||
|
||||
func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError>
|
||||
}
|
||||
@@ -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<String, HealthChatAPIError>
|
||||
|
||||
/// 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<String, HealthChatAPIError> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<URLRequest?> = .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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Captured> = .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<UInt8>.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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user