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:
2026-09-22 16:56:20 +02:00
committed by Sedat Qaja
parent ea07f76c60
commit e5bfc3a26d
19 changed files with 1807 additions and 0 deletions
+76
View File
@@ -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))
}
}