diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 35b8a4993..4703fd47f 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -659,10 +659,6 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "SeparatorRoomTimelineView_Previews") } - func testServerConfirmationScreen() async throws { - try await performAccessibilityAudit(named: "ServerConfirmationScreen_Previews") - } - func testServerSelection() async throws { try await performAccessibilityAudit(named: "ServerSelection_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 398333e50..4b9046aa0 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -300,7 +300,6 @@ 2DD9D0FE7CB5CFC80D071451 /* AppLockScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3E67E09FE5A35D73818C39 /* AppLockScreenModels.swift */; }; 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */; }; 2E6AD068D7767BDC33626F1C /* SnapshotableGlassEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB04B2D794885025DACFCEFB /* SnapshotableGlassEffect.swift */; }; - 2E8C6672D0EE7D5B1BEDB8E2 /* ServerConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7478623CECC9438014244BA /* ServerConfirmationScreen.swift */; }; 2EAA1B35D9CA24F090F48792 /* SpaceRoomListProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACD4855BDAD0799FD86B7B5 /* SpaceRoomListProxyProtocol.swift */; }; 2F2906AE9BC3D0E79A6F98F8 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E89E530A8E92EC44301CA1 /* Bundle.swift */; }; 2F6207CB5C4715FE313B1E95 /* TimelineViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6509708F54FC883604DFDC95 /* TimelineViewModelTests.swift */; }; @@ -326,6 +325,7 @@ 33CAC1226DFB8B5D8447D286 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 67E7A6F388D3BF85767609D9 /* Sentry */; }; 33F1FB19F222BA9930AB1A00 /* RoomListFiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6372DD10DED30E7AD7BCE21 /* RoomListFiltersView.swift */; }; 340D39DB87F3800D53A6A621 /* EmojiPickerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */; }; + 34320DB3C585FD34F4DEC5C8 /* HomeserverHistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2BBDB7E80E119D5C906285 /* HomeserverHistoryManager.swift */; }; 34357B287357BC0B9715DD51 /* UserAgentBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */; }; 34390DAE0C574DAD30CCA7D9 /* DeclineAndBlockScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ADF12A50186B75C68017B61 /* DeclineAndBlockScreenViewModelTests.swift */; }; 34ADC506C929D10B138EBA51 /* AdvancedSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C58159916ABD7F454CE689 /* AdvancedSettingsScreenCoordinator.swift */; }; @@ -397,7 +397,6 @@ 3F55721B5C08E8D9F1295592 /* SDKListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5CD2993048222B64C45006 /* SDKListener.swift */; }; 3F70E237CE4C3FAB02FC227F /* NotificationConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C830A64609CBD152F06E0457 /* NotificationConstants.swift */; }; 3F791C4E0F7B373DB217EC90 /* NSItemProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ED859C6363DAB2E8CFF2CE /* NSItemProviderTests.swift */; }; - 401BB28CD6B7DD6B4E7863E7 /* ServerConfirmationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9342F5D6729627B6393AF853 /* ServerConfirmationScreenModels.swift */; }; 4060D5B90302C93F5617B290 /* Macros in Frameworks */ = {isa = PBXBuildFile; productRef = 45CF0AEC68C170C55A613370 /* Macros */; }; 407DCE030E0F9B7C9861D38A /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = 81DB3AB6CE996AB3954F4F03 /* KZFileWatchers */; }; 40839665086F5DAFA55D0EED /* AnalyticsConsentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353024006CB726E9F9187B3A /* AnalyticsConsentState.swift */; }; @@ -513,7 +512,6 @@ 52BE121FC21CE88803D2D8B5 /* GalleryItemTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7927284C6F70D60BE5949F8C /* GalleryItemTileView.swift */; }; 5341D48F833E3E30F16FA2A3 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2910422CB628D3B2BBE47449 /* SeparatorRoomTimelineView.swift */; }; 5375902175B2FEA2949D7D74 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDDDDD9FE1A699D23A5E096 /* LoginScreen.swift */; }; - 53A55748D5F587C9061F98BF /* ServerConfigurationScreenViewStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */; }; 53A59720F4729D9BBFFB7CAB /* NotificationSettingsEditScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9CB3B9DFA353AB2B7CD9F8 /* NotificationSettingsEditScreenCoordinator.swift */; }; 53C1E7F6A7D6409D89F36ED7 /* AggregatedReactionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */; }; 53DEF39F0C4DE02E3FC56D91 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 800631D7250B7F93195035F1 /* KeychainAccess */; }; @@ -547,7 +545,6 @@ 585DCA0487A0A6F4E59EF5CA /* AnalyticsConsentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353024006CB726E9F9187B3A /* AnalyticsConsentState.swift */; }; 5877255D6E6ED898D402ED8D /* LocationPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408ACC0D28656F82A5EB6A7E /* LocationPickerSheet.swift */; }; 588411C8FD72B2A2DFE5F7DE /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E992D7B8BE54B2AB454613AF /* XCUIElement.swift */; }; - 5894C2514400A4FBC9327632 /* ServerConfirmationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03277E40D0E0DE0712021A71 /* ServerConfirmationScreenCoordinator.swift */; }; 5897A59DDBD3592282092223 /* MediaSourceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49B9785E3AD7D1C15A29F2F /* MediaSourceProxy.swift */; }; 58F357A9D130A654ABCB1638 /* RageshakeConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC598338E7CF41107293AB5 /* RageshakeConfiguration.swift */; }; 592A5B88471676D6F50B9798 /* SearchScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6B10AEA5983B4A2336387B /* SearchScreenCoordinator.swift */; }; @@ -705,6 +702,7 @@ 71AF9345A1202DB9B5DCD9AE /* Macros in Frameworks */ = {isa = PBXBuildFile; productRef = AF574A0FC6F3A83D3BFFC9B3 /* Macros */; }; 71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */; }; 71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; }; + 723382158BFD148984201266 /* HomeserverHistoryManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9292E3516E65DED7130684 /* HomeserverHistoryManagerTests.swift */; }; 7254FB2EFDD43BC8BB7A1213 /* SecurityAndPrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AE42C19EDE64B7CB7BE4D0 /* SecurityAndPrivacyScreen.swift */; }; 726AA74DF4E5EFCEBD78CE3F /* RoomMembersFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */; }; 72D2298DE695A6797CDA1A2A /* SpaceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */; }; @@ -857,7 +855,6 @@ 890F0D453FE388756479AC97 /* AnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C687844F60BFF532D49A994C /* AnalyticsTests.swift */; }; 89198AE2649DD77673D5793B /* ExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8571A8A071FB41778C016 /* ExtensionLogger.swift */; }; 8944548A684F1C837CEC47F4 /* RoomMembersListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */; }; - 89658A44C9FC19B58FD1C226 /* ServerConfirmationScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */; }; 899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA60F848D1C14F873F9621A /* RoomMemberDetailsScreenCoordinator.swift */; }; 89B909AC66B96FA054EF3C14 /* InvitedRoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E95B3BDB80531C85CD50AE6 /* InvitedRoomProxy.swift */; }; 89DF67AECBF9D0EE0DDB7737 /* Tracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B574805B9812C111D6215D /* Tracing.swift */; }; @@ -1427,6 +1424,7 @@ E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */; }; E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */; }; E8F88136C8E69D9E69621E19 /* ContentScannerServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA9E2B1B5038DCABADA7D92 /* ContentScannerServiceProtocol.swift */; }; + E917AA8ED28D706648102047 /* TextFieldAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF4BC6D41585235BDFF4DE6 /* TextFieldAdapter.swift */; }; E9347F56CF0683208F4D9249 /* RoomNotificationSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A9B5225D0881CEFA2CF7C9 /* RoomNotificationSettingsScreenViewModel.swift */; }; E938F7A45D6A3DBBE6789A03 /* NSEUserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C9651CD1066F239C7739240 /* NSEUserSession.swift */; }; E9560744F7B0292E20ECE5F2 /* RoomDetailsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E8A1E8EE094F570573B6E8 /* RoomDetailsScreenViewModelProtocol.swift */; }; @@ -1478,7 +1476,6 @@ F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; }; F08F7BC07CA9AEF5CD157918 /* Snapshotting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF17EA323AD0205A6AB621AA /* Snapshotting.swift */; }; F0A027BB2369606DBDE3BDAD /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 32B8F4CD937AA9C1F8FC3CBC /* KeychainAccess */; }; - F0A26CD502C3A5868353B0FA /* ServerConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24DEE0682C95F897B6C7CB0D /* ServerConfirmationScreenViewModel.swift */; }; F0F82C3C848C865C3098AA52 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = BA93CD75CCE486660C9040BD /* Collections */; }; F103924DED414ADFE398CE99 /* RoomPollsHistoryScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A130A2251A15A7AACC84FD37 /* RoomPollsHistoryScreenViewModelProtocol.swift */; }; F118DD449066E594F63C697D /* RoomMemberProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5E17028C02DFA7DDA3931 /* RoomMemberProxyProtocol.swift */; }; @@ -1559,7 +1556,6 @@ FD19654C18E7C6D8AC79408D /* libMatrixRustSDKMocks.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 73F3153AB9D628110878E24F /* libMatrixRustSDKMocks.a */; }; FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */; }; FD573B5D665824EB79EABF06 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5327E3B3C58BEB0E65F4CF98 /* Observable.swift */; }; - FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */; }; FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.swift */; }; FDC67E8C0EDCB00ABC66C859 /* landscape_test_video.mov in Resources */ = {isa = PBXBuildFile; fileRef = 78BBDF7A05CF53B5CDC13682 /* landscape_test_video.mov */; }; FDD5B4B616D9FF4DE3E9A418 /* QRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */; }; @@ -1679,7 +1675,6 @@ 02D155E09BF961BBA8F85263 /* InviteUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenViewModel.swift; sourceTree = ""; }; 02EE0FABA8ED6D6C1D6CE71D /* ReactionsSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsSummaryView.swift; sourceTree = ""; }; 0315C328FF40F84276364E66 /* SecurityAndPrivacyScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityAndPrivacyScreenViewModelTests.swift; sourceTree = ""; }; - 03277E40D0E0DE0712021A71 /* ServerConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenCoordinator.swift; sourceTree = ""; }; 0328F54E0C3AAEDDF3E05D9D /* ChatsTabFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsTabFlowCoordinatorTests.swift; sourceTree = ""; }; 033DB41C51865A2E83174E87 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; 035177BCD8E8308B098AC3C2 /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; @@ -1892,7 +1887,6 @@ 2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerTests.swift; sourceTree = ""; }; 2441E2424E78A40FC95DBA76 /* AudioRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderTests.swift; sourceTree = ""; }; 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyMock.swift; sourceTree = ""; }; - 24DEE0682C95F897B6C7CB0D /* ServerConfirmationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModel.swift; sourceTree = ""; }; 24E637CF570711FB5FD63DEA /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; 24EC819497BB5F8C4998D760 /* RoomListFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFilterView.swift; sourceTree = ""; }; 24F5530B2212862FA4BEFF2D /* HomeScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -1909,7 +1903,6 @@ 2721D7B051F0159AA919DA05 /* RoomChangePermissionsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenViewModelProtocol.swift; sourceTree = ""; }; 2757B1BE23DF8AA239937243 /* AudioConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterProtocol.swift; sourceTree = ""; }; 276833816F5DEB1CE3B8BE1E /* ReportRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreen.swift; sourceTree = ""; }; - 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigurationScreenViewStateTests.swift; sourceTree = ""; }; 27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = ""; }; 27A5FBB74981BF8EFFDAE90D /* HTMLFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFixtures.swift; sourceTree = ""; }; 27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = ""; }; @@ -2290,6 +2283,7 @@ 6C9651CD1066F239C7739240 /* NSEUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEUserSession.swift; sourceTree = ""; }; 6CD4823EAB4B4E8BAB4F6B8C /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsScreenIdentifier.swift; sourceTree = ""; }; + 6D2BBDB7E80E119D5C906285 /* HomeserverHistoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeserverHistoryManager.swift; sourceTree = ""; }; 6DB53055CB130F0651C70763 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 6DF438EAFC732D2D95D34BF6 /* StartChatViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatViewModelTests.swift; sourceTree = ""; }; 6E5E9C044BEB7C70B1378E91 /* UserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; @@ -2384,6 +2378,7 @@ 7C28B70BEFD3676F11D5D51F /* RoomRolesAndPermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsScreenCoordinator.swift; sourceTree = ""; }; 7C71B9802433F1B4252291BB /* IdentityConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenViewModelProtocol.swift; sourceTree = ""; }; 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreenViewModel.swift; sourceTree = ""; }; + 7CF4BC6D41585235BDFF4DE6 /* TextFieldAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldAdapter.swift; sourceTree = ""; }; 7D0CBC76C80E04345E11F2DB /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactoryProtocol.swift; sourceTree = ""; }; 7D39AF1F659923D77778511E /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2518,7 +2513,6 @@ 92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScannerView.swift; sourceTree = ""; }; 9332DFE9642F0A46ECA0497B /* BlurHashEncode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = ""; }; 933B074F006F8E930DB98B4E /* TimelineMediaFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaFrame.swift; sourceTree = ""; }; - 9342F5D6729627B6393AF853 /* ServerConfirmationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenModels.swift; sourceTree = ""; }; 9349F590E35CE514A71E6764 /* LoginHomeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginHomeserver.swift; sourceTree = ""; }; 93C713D124FE915ABF47A6B7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModelTests.swift; sourceTree = ""; }; @@ -2658,7 +2652,6 @@ AB389C38BD41EB3E47092CFB /* AccessibilityTests.xctestplan */ = {isa = PBXFileReference; path = AccessibilityTests.xctestplan; sourceTree = ""; }; AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommonSettings+NotificationName.swift"; sourceTree = ""; }; AB7D79FA27D094146E2B5C9D /* TimelineMediaContentScanningFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaContentScanningFailureView.swift; sourceTree = ""; }; - ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = ""; }; ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageTrashButton.swift; sourceTree = ""; }; AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelTests.swift; sourceTree = ""; }; AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = ""; }; @@ -2942,6 +2935,7 @@ DB08D1F7C27A8C24EF81073C /* MapURLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapURLs.swift; sourceTree = ""; }; DB67A042079F7B250A62C8FB /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/SAS.strings; sourceTree = ""; }; DB6F137B69DCB59353E37B80 /* TimelineItemKeyForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemKeyForwarder.swift; sourceTree = ""; }; + DB9292E3516E65DED7130684 /* HomeserverHistoryManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeserverHistoryManagerTests.swift; sourceTree = ""; }; DBEDCEC9D908C19C63D24395 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNNotification+Creator.swift"; sourceTree = ""; }; DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -3068,7 +3062,6 @@ F012CB5EE3F2B67359F6CC52 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; F0205C03F98BE861EDABCB0D /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/Localizable.strings; sourceTree = ""; }; F03AEAA2F66E796C365EFD58 /* ElementNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementNavigationStack.swift; sourceTree = ""; }; - F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelTests.swift; sourceTree = ""; }; F0E14FF533D25A0692F7CEB0 /* RoomPollsHistoryScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenViewModel.swift; sourceTree = ""; }; F1033290D99D5BBA1AF3560A /* MapTilerURLBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerURLBuilderProtocol.swift; sourceTree = ""; }; F104596B0620CEFE5DFD31B1 /* RoomSelectionScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSelectionScreenCoordinator.swift; sourceTree = ""; }; @@ -3108,7 +3101,6 @@ F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProviderProtocol.swift; sourceTree = ""; }; F64A8582F65567AC38C2976A /* PollFormScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFormScreenViewModel.swift; sourceTree = ""; }; F72EFC8C634469F9262659C7 /* NSItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProvider.swift; sourceTree = ""; }; - F7478623CECC9438014244BA /* ServerConfirmationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreen.swift; sourceTree = ""; }; F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackSpeed.swift; sourceTree = ""; }; F7E247E29C7A0E421DB839D7 /* RoomPowerLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevel.swift; sourceTree = ""; }; F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = ""; }; @@ -3766,14 +3758,6 @@ path = RoomMember; sourceTree = ""; }; - 2CAAA563083EAC0FA6BDC4F3 /* View */ = { - isa = PBXGroup; - children = ( - F7478623CECC9438014244BA /* ServerConfirmationScreen.swift */, - ); - path = View; - sourceTree = ""; - }; 2D0D49B0533C4C2EB889BF3A /* ServerSelectionScreen */ = { isa = PBXGroup; children = ( @@ -5074,6 +5058,7 @@ 1A7ED2EF5BDBAD2A7DBC4636 /* GeoURITests.swift */, B73587C2E3CF5998361AE516 /* HomeScreenRoomTests.swift */, 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */, + DB9292E3516E65DED7130684 /* HomeserverHistoryManagerTests.swift */, ECBBCD7444C2B515D02EC0E8 /* IdentityConfirmationScreenViewModelTests.swift */, 845DDBDE5A0887E73D38B826 /* InviteUsersViewModelTests.swift */, DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */, @@ -5136,8 +5121,6 @@ 5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */, 848F69921527D31CAACB93AF /* SecureBackupLogoutConfirmationScreenViewModelTests.swift */, 0315C328FF40F84276364E66 /* SecurityAndPrivacyScreenViewModelTests.swift */, - 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */, - F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */, E45EBAFF1A83538D54ABDF92 /* ServerSelectionScreenViewModelTests.swift */, 0825EAFD47332DD459DE893F /* SessionDirectoriesTests.swift */, A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */, @@ -5418,6 +5401,7 @@ 0724EBDFE8BB4C9E5547C57D /* Client.swift */, 18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */, 6033779EB37259F27F938937 /* ClientProxyProtocol.swift */, + 6D2BBDB7E80E119D5C906285 /* HomeserverHistoryManager.swift */, ); path = Client; sourceTree = ""; @@ -6321,18 +6305,6 @@ path = RoomMemberDetailsScreen; sourceTree = ""; }; - BA1938A75D8C780F694CEB62 /* ServerConfirmationScreen */ = { - isa = PBXGroup; - children = ( - 03277E40D0E0DE0712021A71 /* ServerConfirmationScreenCoordinator.swift */, - 9342F5D6729627B6393AF853 /* ServerConfirmationScreenModels.swift */, - 24DEE0682C95F897B6C7CB0D /* ServerConfirmationScreenViewModel.swift */, - ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */, - 2CAAA563083EAC0FA6BDC4F3 /* View */, - ); - path = ServerConfirmationScreen; - sourceTree = ""; - }; BB11CF5847CA0B3BC3A7F8DA /* View */ = { isa = PBXGroup; children = ( @@ -6415,6 +6387,7 @@ 53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */, FE5CD2993048222B64C45006 /* SDKListener.swift */, 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */, + 7CF4BC6D41585235BDFF4DE6 /* TextFieldAdapter.swift */, 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */, 7431C962E314ADAE38B6D708 /* Analytics */, 9F7C2D63C42828D8931D5286 /* CollapsibleFlowLayout */, @@ -6992,7 +6965,6 @@ AEA11EF88B2848B12FA6FA36 /* OAuthAuthenticationPresenter.swift */, 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */, 90F48FEF84016ED42A94BA24 /* LoginScreen */, - BA1938A75D8C780F694CEB62 /* ServerConfirmationScreen */, 2D0D49B0533C4C2EB889BF3A /* ServerSelectionScreen */, 5B2C520AB9863B8CBC8EB3CA /* SoftLogoutScreen */, A3C1613EC54531E5BC4CC835 /* StartScreen */, @@ -7660,16 +7632,16 @@ projectDirPath = ""; projectRoot = ""; targets = ( - C0FAEB81CFD9776CD78CE489 /* ElementX */, - FEB53A5BC378C913769656D8 /* NSE */, - 19F0C845D67E9BEA4BE7133E /* ShareExtension */, - 676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */, - 32C23C8D224D46EFE62AFAD0 /* UnitTests */, - 7A17BE29BAC81ADBAC6349D9 /* PreviewTests */, - 0E28CD62691FDBC63147D5E3 /* UITests */, - D3DB351B7FBE0F49649171FC /* IntegrationTests */, C0C687DE1D270F9895FEE186 /* AccessibilityTests */, + C0FAEB81CFD9776CD78CE489 /* ElementX */, + D3DB351B7FBE0F49649171FC /* IntegrationTests */, + 676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */, + FEB53A5BC378C913769656D8 /* NSE */, F8E276FD6DC43EADB85241BC /* Periphery */, + 7A17BE29BAC81ADBAC6349D9 /* PreviewTests */, + 19F0C845D67E9BEA4BE7133E /* ShareExtension */, + 0E28CD62691FDBC63147D5E3 /* UITests */, + 32C23C8D224D46EFE62AFAD0 /* UnitTests */, ); }; /* End PBXProject section */ @@ -8078,6 +8050,7 @@ 07756D532EFE33DD1FA258E5 /* GeoURITests.swift in Sources */, 6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */, F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */, + 723382158BFD148984201266 /* HomeserverHistoryManagerTests.swift in Sources */, 00091F143129B9A4904563C4 /* IdentityConfirmationScreenViewModelTests.swift in Sources */, A23B8B27A1436A1049EEF68E /* InfoPlistReader.swift in Sources */, A216C83ADCF32BA5EF8A6FBC /* InviteUsersViewModelTests.swift in Sources */, @@ -8150,8 +8123,6 @@ D69CCEC785E6C819551B402F /* SearchScreenViewModelTests.swift in Sources */, EB87DF90CF6F8D5D12404C6E /* SecureBackupLogoutConfirmationScreenViewModelTests.swift in Sources */, CB9FB2BEF313072C705AC9B5 /* SecurityAndPrivacyScreenViewModelTests.swift in Sources */, - 53A55748D5F587C9061F98BF /* ServerConfigurationScreenViewStateTests.swift in Sources */, - 89658A44C9FC19B58FD1C226 /* ServerConfirmationScreenViewModelTests.swift in Sources */, 67ECD32538F6DAFE38A623F9 /* ServerSelectionScreenViewModelTests.swift in Sources */, CC1C948F67A5510A340FD7F0 /* SessionDirectoriesTests.swift in Sources */, 86675910612A12409262DFBD /* SessionVerificationStateMachineTests.swift in Sources */, @@ -8596,6 +8567,7 @@ 56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */, C0A845AAFEB7E1BEAE04306E /* HomeserverCapabilitiesProxy.swift in Sources */, 97550ECE8A1D28FEF74FBACC /* HomeserverCapabilitiesProxyProtocol.swift in Sources */, + 34320DB3C585FD34F4DEC5C8 /* HomeserverHistoryManager.swift in Sources */, 277FD4394EAFF323DA34997E /* HorizontalHighlightGradient.swift in Sources */, 2BBE320EE426A347AAE5C7DA /* IdentityConfirmationScreen.swift in Sources */, C3BB6887CF13B19182E81F87 /* IdentityConfirmationScreenCoordinator.swift in Sources */, @@ -9105,11 +9077,6 @@ 08547E55DD3686A84550996D /* SeparatorMediaEventsTimelineView.swift in Sources */, 14E99D27628B1A6F0CB46FEA /* SeparatorRoomTimelineItem.swift in Sources */, 5341D48F833E3E30F16FA2A3 /* SeparatorRoomTimelineView.swift in Sources */, - 2E8C6672D0EE7D5B1BEDB8E2 /* ServerConfirmationScreen.swift in Sources */, - 5894C2514400A4FBC9327632 /* ServerConfirmationScreenCoordinator.swift in Sources */, - 401BB28CD6B7DD6B4E7863E7 /* ServerConfirmationScreenModels.swift in Sources */, - F0A26CD502C3A5868353B0FA /* ServerConfirmationScreenViewModel.swift in Sources */, - FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */, 43F35A7E5703D64DB0519C59 /* ServerSelectionScreen.swift in Sources */, E5F4C992845388B50BABACAA /* ServerSelectionScreenCoordinator.swift in Sources */, 6530865EB9A8C0F0AF0216DA /* ServerSelectionScreenModels.swift in Sources */, @@ -9212,6 +9179,7 @@ 1ECE584D2CFA0FEF0ADE458C /* TestablePreviewsDictionary.swift in Sources */, 887AC93C523AEFB640EA5EC8 /* TextBasedRoomTimelineItem.swift in Sources */, 4FE688FE9375B2FBF424146A /* TextBasedRoomTimelineViewProtocol.swift in Sources */, + E917AA8ED28D706648102047 /* TextFieldAdapter.swift in Sources */, BB784A02BADB03C820617A46 /* TextRoomTimelineItem.swift in Sources */, 53F1196F9C69512306A2693F /* TextRoomTimelineItemContent.swift in Sources */, D8CFA0EE46376F9FF04EEE45 /* TextRoomTimelineView.swift in Sources */, diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 547f88c41..16db8b87c 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -182,6 +182,14 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: AppAppearance.system) var appAppearance: AppAppearance + /// Tracks previous servers the user connected to for autocompletion purposes. Entries are made lowercase on write. + @UserPreference(key: "previousServers", defaultValue: []) + var previousServers: [String] + + var defaultServer: String { + previousServers.first ?? accountProviders[0] + } + // MARK: - Security /// The app must be locked with a PIN code as part of the authentication flow. diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index be70f5e9e..a644f2074 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -21,6 +21,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { private let navigationStackCoordinator: NavigationStackCoordinator private let appMediator: AppMediatorProtocol private let appSettings: AppSettings + private let homeserverHistoryManager: HomeserverHistoryManager private let appHooks: AppHooks private let userIndicatorController: UserIndicatorControllerProtocol @@ -34,10 +35,9 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { /// The screen used for the whole QR Code flow. case qrCodeLoginScreen - /// The screen to continue authentication with the current server. - case serverConfirmationScreen - /// The screen to choose a different server. + /// The screen used to choose a server case serverSelectionScreen + /// The screen to login with a password. case loginScreen @@ -60,17 +60,12 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { /// The user would like to login with a QR code. case loginWithQR /// Show the server confirmation screen. - case confirmServer(AuthenticationFlow) + case selectServer(AuthenticationFlow) /// The QR login flow was aborted. case cancelledLoginWithQR /// The user aborted manual login. - case cancelledServerConfirmation - - /// The user would like to enter a different server. - case changeServer(AuthenticationFlow) - /// The user is no longer selecting a server. - case dismissedServerSelection + case cancelledServerSelection /// Show the screen to login with password (with the optional login hint in the `userInfo`). case continueWithPassword @@ -113,6 +108,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { self.navigationRootCoordinator = navigationRootCoordinator self.appMediator = appMediator self.appSettings = appSettings + homeserverHistoryManager = HomeserverHistoryManager(appSettings: appSettings) self.appHooks = appHooks self.userIndicatorController = userIndicatorController @@ -155,10 +151,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { case .qrCodeLoginScreen: navigationStackCoordinator.setSheetCoordinator(nil) stateMachine.tryEvent(.cancelledLoginWithQR) // Needs to be handled manually. - case .serverConfirmationScreen: - navigationStackCoordinator.popToRoot(animated: animated) case .serverSelectionScreen: - navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.popToRoot(animated: animated) case .loginScreen: navigationStackCoordinator.popToRoot(animated: animated) @@ -202,28 +195,20 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { // Manual Authentication - stateMachine.addRoutes(event: .confirmServer(.login), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in - self?.showServerConfirmationScreen(authenticationFlow: .login) - } - stateMachine.addRoutes(event: .confirmServer(.register), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in - self?.showServerConfirmationScreen(authenticationFlow: .register) - } - stateMachine.addRoutes(event: .cancelledServerConfirmation, transitions: [.serverConfirmationScreen => .startScreen]) - - stateMachine.addRoutes(event: .changeServer(.login), transitions: [.serverConfirmationScreen => .serverSelectionScreen]) { [weak self] _ in + stateMachine.addRoutes(event: .selectServer(.login), transitions: [.startScreen => .serverSelectionScreen]) { [weak self] _ in self?.showServerSelectionScreen(authenticationFlow: .login) } - stateMachine.addRoutes(event: .changeServer(.register), transitions: [.serverConfirmationScreen => .serverSelectionScreen]) { [weak self] _ in + stateMachine.addRoutes(event: .selectServer(.register), transitions: [.startScreen => .serverSelectionScreen]) { [weak self] _ in self?.showServerSelectionScreen(authenticationFlow: .register) } - stateMachine.addRoutes(event: .dismissedServerSelection, transitions: [.serverSelectionScreen => .serverConfirmationScreen]) + stateMachine.addRoutes(event: .cancelledServerSelection, transitions: [.serverSelectionScreen => .startScreen]) - stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen, + stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverSelectionScreen => .loginScreen, .startScreen => .loginScreen]) { [weak self] context in let loginHint = context.userInfo as? String self?.showLoginScreen(loginHint: loginHint, fromState: context.fromState) } - stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverConfirmationScreen), transitions: [.loginScreen => .serverConfirmationScreen]) + stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverSelectionScreen), transitions: [.loginScreen => .serverSelectionScreen]) stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .startScreen), transitions: [.loginScreen => .startScreen]) // Bug Report @@ -243,7 +228,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { // Completion stateMachine.addRoutes(event: .signedIn, transitions: [.qrCodeLoginScreen => .complete, - .serverConfirmationScreen => .complete, // OAuth authentication + .serverSelectionScreen => .complete, // OAuth authentication .startScreen => .complete, // Direct OAuth authentication .loginScreen => .complete]) { [weak self] context in guard let userSession = context.userInfo as? UserSessionProtocol else { fatalError("The user session wasn't included in the context") } @@ -292,9 +277,9 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { case .loginWithQR: stateMachine.tryEvent(.loginWithQR) case .login: - stateMachine.tryEvent(.confirmServer(.login)) + stateMachine.tryEvent(.selectServer(.login)) case .register: - stateMachine.tryEvent(.confirmServer(.register)) + stateMachine.tryEvent(.selectServer(.register)) case .loginDirectlyWithOAuth(let oAuthData, let window): showOAuthAuthentication(oAuthData: oAuthData, presentationAnchor: window) @@ -336,7 +321,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { case .signInManually: navigationStackCoordinator.setSheetCoordinator(nil) stateMachine.tryEvent(.cancelledLoginWithQR) - stateMachine.tryEvent(.confirmServer(.login)) + stateMachine.tryEvent(.selectServer(.login)) case .signedIn(let userSession): navigationStackCoordinator.setSheetCoordinator(nil) DispatchQueue.main.async { @@ -355,42 +340,15 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { // MARK: - Manual Authentication - private func showServerConfirmationScreen(authenticationFlow: AuthenticationFlow) { + private func showServerSelectionScreen(authenticationFlow: AuthenticationFlow) { // Reset the service back to the default homeserver before continuing. This ensures // we check that registration is supported if it was previously configured for login. authenticationService.reset() - let parameters = ServerConfirmationScreenCoordinatorParameters(authenticationService: authenticationService, - authenticationFlow: authenticationFlow, - appSettings: appSettings, - userIndicatorController: userIndicatorController) - let coordinator = ServerConfirmationScreenCoordinator(parameters: parameters) - - coordinator.actions.sink { [weak self] action in - guard let self else { return } - - switch action { - case .continueWithOAuth(let oAuthData, let window): - showOAuthAuthentication(oAuthData: oAuthData, presentationAnchor: window) - case .continueWithPassword: - stateMachine.tryEvent(.continueWithPassword) - case .changeServer: - stateMachine.tryEvent(.changeServer(authenticationFlow)) - } - } - .store(in: &cancellables) - - navigationStackCoordinator.push(coordinator) { [weak self] in - self?.stateMachine.tryEvent(.cancelledServerConfirmation) - } - } - - private func showServerSelectionScreen(authenticationFlow: AuthenticationFlow) { - let navigationCoordinator = NavigationStackCoordinator() - let parameters = ServerSelectionScreenCoordinatorParameters(authenticationService: authenticationService, authenticationFlow: authenticationFlow, appSettings: appSettings, + homeserverHistoryManager: homeserverHistoryManager, userIndicatorController: userIndicatorController) let coordinator = ServerSelectionScreenCoordinator(parameters: parameters) @@ -399,17 +357,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { guard let self else { return } switch action { - case .updated: - navigationStackCoordinator.setSheetCoordinator(nil) - case .dismiss: - navigationStackCoordinator.setSheetCoordinator(nil) + case .continueWithOAuth(let oAuthData, let window): + showOAuthAuthentication(oAuthData: oAuthData, presentationAnchor: window) + case .continueWithPassword: + stateMachine.tryEvent(.continueWithPassword) } } .store(in: &cancellables) - navigationCoordinator.setRootCoordinator(coordinator) - navigationStackCoordinator.setSheetCoordinator(navigationCoordinator) { [weak self] in - self?.stateMachine.tryEvent(.dismissedServerSelection) + navigationStackCoordinator.push(coordinator) { [weak self] in + self?.stateMachine.tryEvent(.cancelledServerSelection) } } @@ -507,5 +464,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { private func userHasSignedIn(userSession: UserSessionProtocol) { delegate?.authenticationFlowCoordinator(didLoginWithSession: userSession) + + let newServer = authenticationService.homeserver.value.address + homeserverHistoryManager.addServerToList(newServer) } } diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index defe3a487..545ae70ea 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -164,7 +164,6 @@ enum TestablePreviewsDictionary { "SendInviteConfirmationView_Previews" : SendInviteConfirmationView_Previews.self, "SeparatorMediaEventsTimelineView_Previews" : SeparatorMediaEventsTimelineView_Previews.self, "SeparatorRoomTimelineView_Previews" : SeparatorRoomTimelineView_Previews.self, - "ServerConfirmationScreen_Previews" : ServerConfirmationScreen_Previews.self, "SessionVerificationRequestDetailsView_Previews" : SessionVerificationRequestDetailsView_Previews.self, "SessionVerification_Previews" : SessionVerification_Previews.self, "SettingsScreenUserStatusPickerView_Previews" : SettingsScreenUserStatusPickerView_Previews.self, diff --git a/ElementX/Sources/Other/TextFieldAdapter.swift b/ElementX/Sources/Other/TextFieldAdapter.swift new file mode 100644 index 000000000..3df702588 --- /dev/null +++ b/ElementX/Sources/Other/TextFieldAdapter.swift @@ -0,0 +1,100 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import UIKit + +/// When leveraging introspection, this allows intercepting UITextFieldDelegate calls +class TextFieldAdapter: NSObject, UITextFieldDelegate { + var textField: UITextField? { + didSet { + oldValue?.delegate = nil + passthroughDelegate = textField?.delegate + textField?.delegate = self + } + } + + /// the original swiftui delegate + private var passthroughDelegate: UITextFieldDelegate? + + private let keystrokeSubject = PassthroughSubject() + /// fires when keystrokes are made. does not fire for deletions or programmatic text changes + var keystrokePublisher: AnyPublisher { + keystrokeSubject.eraseToAnyPublisher() + } + + func textField(_ textField: UITextField, shouldChangeCharactersInRanges ranges: [NSValue], replacementString string: String) -> Bool { + guard let range = ranges.first?.rangeValue else { return true } + if string.isEmpty == false { // don't fire for backspaces + let previousText = textField.text ?? "" + + let completeString = (previousText as NSString).replacingCharacters(in: range, with: string) + + keystrokeSubject.send(completeString) + } + return true + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + self.textField(textField, shouldChangeCharactersInRanges: [NSValue(range: range)], replacementString: string) + } + + // MARK: - Passthrough + + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + passthroughDelegate?.textFieldShouldBeginEditing?(textField) ?? true + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + passthroughDelegate?.textFieldDidBeginEditing?(textField) + } + + func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { + passthroughDelegate?.textFieldShouldEndEditing?(textField) ?? true + } + + func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { + passthroughDelegate?.textFieldDidEndEditing?(textField, reason: reason) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + passthroughDelegate?.textFieldDidEndEditing?(textField) + } + + func textFieldShouldClear(_ textField: UITextField) -> Bool { + passthroughDelegate?.textFieldShouldClear?(textField) ?? true + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + passthroughDelegate?.textFieldShouldReturn?(textField) ?? true + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + passthroughDelegate?.textFieldDidChangeSelection?(textField) + } + + @available(iOS 26.0, *) + func textField(_ textField: UITextField, editMenuForCharactersInRanges ranges: [NSValue], suggestedActions: [UIMenuElement]) -> UIMenu? { + passthroughDelegate?.textField?(textField, editMenuForCharactersInRanges: ranges, suggestedActions: suggestedActions) + } + + func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + passthroughDelegate?.textField?(textField, editMenuForCharactersIn: range, suggestedActions: suggestedActions) + } + + func textField(_ textField: UITextField, willPresentEditMenuWith animator: any UIEditMenuInteractionAnimating) { + passthroughDelegate?.textField?(textField, willPresentEditMenuWith: animator) + } + + func textField(_ textField: UITextField, willDismissEditMenuWith animator: any UIEditMenuInteractionAnimating) { + passthroughDelegate?.textField?(textField, willDismissEditMenuWith: animator) + } + + func textField(_ textField: UITextField, insertInputSuggestion inputSuggestion: UIInputSuggestion) { + passthroughDelegate?.textField?(textField, insertInputSuggestion: inputSuggestion) + } +} diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenCoordinator.swift deleted file mode 100644 index 8ddd70042..000000000 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenCoordinator.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Combine -import SwiftUI - -struct ServerConfirmationScreenCoordinatorParameters { - let authenticationService: AuthenticationServiceProtocol - let authenticationFlow: AuthenticationFlow - let appSettings: AppSettings - let userIndicatorController: UserIndicatorControllerProtocol -} - -enum ServerConfirmationScreenCoordinatorAction { - case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow) - case continueWithPassword - case changeServer -} - -final class ServerConfirmationScreenCoordinator: CoordinatorProtocol { - private var viewModel: ServerConfirmationScreenViewModelProtocol - private let actionsSubject: PassthroughSubject = .init() - private var cancellables = Set() - - var actions: AnyPublisher { - actionsSubject.eraseToAnyPublisher() - } - - init(parameters: ServerConfirmationScreenCoordinatorParameters) { - let mode = if parameters.appSettings.allowOtherAccountProviders { - ServerConfirmationScreenMode.confirmation(parameters.authenticationService.homeserver.value.address) - } else { - ServerConfirmationScreenMode.picker(parameters.appSettings.accountProviders) - } - - viewModel = ServerConfirmationScreenViewModel(authenticationService: parameters.authenticationService, - mode: mode, - authenticationFlow: parameters.authenticationFlow, - appSettings: parameters.appSettings, - userIndicatorController: parameters.userIndicatorController) - } - - func start() { - viewModel.actions.sink { [weak self] action in - guard let self else { return } - - switch action { - case .continueWithOAuth(let oAuthData, let window): - actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window)) - case .continueWithPassword: - actionsSubject.send(.continueWithPassword) - case .changeServer: - actionsSubject.send(.changeServer) - } - } - .store(in: &cancellables) - } - - func toPresentable() -> AnyView { - AnyView(ServerConfirmationScreen(context: viewModel.context)) - } -} diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenModels.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenModels.swift deleted file mode 100644 index dc71e4df3..000000000 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenModels.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import SwiftUI - -enum ServerConfirmationScreenViewModelAction { - /// Continue the flow using the provided OAuth parameters. - case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow) - /// Continue the flow using password authentication. - case continueWithPassword - /// The user would like to change to a different homeserver. - case changeServer -} - -enum ServerConfirmationScreenMode: Equatable { - /// The user is confirming the displayed account provider (or can enter their own). - case confirmation(String) - /// The user is only allowed to pick from a list of account providers. - case picker([String]) -} - -struct ServerConfirmationScreenViewState: BindableState { - /// Whether the screen is configured to confirm a single account provider or pick one from a list of many. - var mode: ServerConfirmationScreenMode - /// The flow being attempted on the selected homeserver. - let authenticationFlow: AuthenticationFlow - /// The presentation anchor used for OAuth authentication. - var window: UIWindow? - - var bindings = ServerConfirmationScreenBindings() - - /// The screen's title. - var title: String { - switch mode { - case .confirmation(let accountProvider): - switch authenticationFlow { - case .login: - L10n.screenServerConfirmationTitleLogin(accountProvider) - case .register: - L10n.screenServerConfirmationTitleRegister(accountProvider) - } - case .picker: - L10n.screenServerConfirmationTitlePickerMode - } - } - - /// The message shown beneath the title. - var message: String? { - guard case let .confirmation(homeserverAddress) = mode else { return nil } - - return switch authenticationFlow { - case .login: - if homeserverAddress == "matrix.org" { - L10n.screenServerConfirmationMessageLoginMatrixDotOrg - } else if homeserverAddress == "element.io" { - L10n.screenServerConfirmationMessageLoginElementDotIo - } else { - "" - } - case .register: - L10n.screenServerConfirmationMessageRegister - } - } -} - -struct ServerConfirmationScreenBindings { - /// The chosen server when in `.picker` mode, otherwise `nil`. - var pickerSelection: String? - /// Information describing the currently displayed alert. - var alertInfo: AlertInfo? -} - -enum ServerConfirmationScreenViewAction { - /// Updates the window used as the OAuth presentation anchor. - case updateWindow(UIWindow) - /// The user would like to continue with the current homeserver. - case confirm - /// The user would like to change to a different homeserver. - case changeServer -} - -enum ServerConfirmationScreenAlert: Hashable { - /// An alert that informs the user that a server could not be found. - case homeserverNotFound - /// An alert that informs the user about a bad well-known file. - case invalidWellKnown(String) - /// An alert that allows the user to learn about sliding sync. - case slidingSync - /// An alert that informs the user that login isn't supported. - case login - /// An alert that informs the user that registration isn't supported. - case registration - /// An alert that informs the user that Element Pro should be used for a particular server. - case elementProRequired(serverName: String) - /// An unknown error has occurred. - case unknownError -} diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift deleted file mode 100644 index b49300d45..000000000 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift +++ /dev/null @@ -1,205 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Combine -import SwiftUI - -typealias ServerConfirmationScreenViewModelType = StateStoreViewModelV2 - -class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType, ServerConfirmationScreenViewModelProtocol { - let authenticationService: AuthenticationServiceProtocol - let authenticationFlow: AuthenticationFlow - let appSettings: AppSettings - let userIndicatorController: UserIndicatorControllerProtocol - - private var actionsSubject: PassthroughSubject = .init() - - var actions: AnyPublisher { - actionsSubject.eraseToAnyPublisher() - } - - init(authenticationService: AuthenticationServiceProtocol, - mode: ServerConfirmationScreenMode, - authenticationFlow: AuthenticationFlow, - appSettings: AppSettings, - userIndicatorController: UserIndicatorControllerProtocol) { - self.authenticationService = authenticationService - self.authenticationFlow = authenticationFlow - self.appSettings = appSettings - self.userIndicatorController = userIndicatorController - - let pickerSelection: String? = switch mode { - case .picker(let providers): providers[0] - case .confirmation: nil - } - - super.init(initialViewState: ServerConfirmationScreenViewState(mode: mode, - authenticationFlow: authenticationFlow, - bindings: .init(pickerSelection: pickerSelection))) - - if case .confirmation = mode { - authenticationService.homeserver - .receive(on: DispatchQueue.main) - .map { .confirmation($0.address) } - .weakAssign(to: \.state.mode, on: self) - .store(in: &cancellables) - } - } - - override func process(viewAction: ServerConfirmationScreenViewAction) { - switch viewAction { - case .updateWindow(let window): - guard state.window != window else { return } - Task { state.window = window } - case .confirm: - switch state.mode { - case .confirmation: - startLoading() - Task { await confirmServer() } - case .picker: - startLoading() - Task { await pickServer() } - } - case .changeServer: - actionsSubject.send(.changeServer) - } - } - - // MARK: - Private - - private func confirmServer() async { - defer { stopLoading() } - startLoading() - - let homeserver = authenticationService.homeserver.value - - // If the login mode is unknown, the service hasn't been configured and we need to do it now. - // Otherwise we can continue the flow as server selection has been performed and succeeded. - guard homeserver.loginMode == .unknown || authenticationService.flow != authenticationFlow else { - await fetchLoginURLIfNeededAndContinue() - return - } - - switch await authenticationService.configure(for: homeserver.address, flow: authenticationFlow) { - case .success: - await fetchLoginURLIfNeededAndContinue() - case .failure(let error): - switch error { - case .invalidServer, .invalidHomeserverAddress: - displayError(.homeserverNotFound) - case .invalidWellKnown(let error): - displayError(.invalidWellKnown(error)) - case .slidingSyncNotAvailable: - displayError(.slidingSync) - case .loginNotSupported: - displayError(.login) - case .registrationNotSupported: - displayError(.registration) - case .elementProRequired(let serverName): - displayError(.elementProRequired(serverName: serverName)) - default: - displayError(.unknownError) - } - } - } - - private func pickServer() async { - defer { stopLoading() } - startLoading() - - guard let accountProvider = state.bindings.pickerSelection else { - fatalError("It shouldn't be possible to confirm without a selection.") - } - - // Don't bother reconfiguring the service if it has already been done for the selected server. - let homeserver = authenticationService.homeserver.value - guard homeserver.loginMode == .unknown || homeserver.address != accountProvider else { - await fetchLoginURLIfNeededAndContinue() - return - } - - switch await authenticationService.configure(for: accountProvider, flow: authenticationFlow) { - case .success: - await fetchLoginURLIfNeededAndContinue() - case .failure: - // When the servers are hard-coded they should have a valid configuration, so show a generic error. - displayError(.unknownError) - } - } - - private func fetchLoginURLIfNeededAndContinue() async { - guard authenticationService.homeserver.value.loginMode.supportsOAuthFlow else { - actionsSubject.send(.continueWithPassword) - return - } - - guard let window = state.window else { - displayError(.unknownError) - return - } - - startLoading() // Uses the same ID, so no need to worry if the indicator already exists - defer { stopLoading() } - - switch await authenticationService.urlForOAuthLogin(loginHint: nil) { - case .success(let oAuthData): - actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window)) - case .failure: - displayError(.unknownError) - } - } - - private let loadingIndicatorID = "\(ServerConfirmationScreenViewModel.self)-Loading" - - private func startLoading() { - userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorID, - type: .modal, - title: L10n.commonLoading, - persistent: true)) - } - - private func stopLoading() { - userIndicatorController.retractIndicatorWithId(loadingIndicatorID) - } - - private func displayError(_ type: ServerConfirmationScreenAlert) { - switch type { - case .homeserverNotFound: - state.bindings.alertInfo = AlertInfo(id: .homeserverNotFound, - title: L10n.errorUnknown, - message: L10n.screenChangeServerErrorInvalidHomeserver) - case .invalidWellKnown(let error): - state.bindings.alertInfo = AlertInfo(id: .invalidWellKnown(error), - title: L10n.commonServerNotSupported, - message: L10n.screenChangeServerErrorInvalidWellKnown(error)) - case .slidingSync: - let nonBreakingAppName = InfoPlistReader.main.bundleDisplayName.replacingOccurrences(of: " ", with: "\u{00A0}") - state.bindings.alertInfo = AlertInfo(id: .slidingSync, - title: L10n.commonServerNotSupported, - message: L10n.screenChangeServerErrorNoSlidingSyncMessage(nonBreakingAppName)) - case .login: - state.bindings.alertInfo = AlertInfo(id: .login, - title: L10n.commonServerNotSupported, - message: L10n.screenLoginErrorUnsupportedAuthentication) - case .registration: - state.bindings.alertInfo = AlertInfo(id: .registration, - title: L10n.commonServerNotSupported, - message: L10n.errorAccountCreationNotPossible) - case .elementProRequired(let serverName): - state.bindings.alertInfo = AlertInfo(id: .elementProRequired(serverName: serverName), - title: L10n.screenChangeServerErrorElementProRequiredTitle, - message: L10n.screenChangeServerErrorElementProRequiredMessage(serverName), - primaryButton: .init(title: L10n.screenChangeServerErrorElementProRequiredActionIos) { - UIApplication.shared.open(self.appSettings.elementProAppStoreURL) - }, - secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil)) - case .unknownError: - state.bindings.alertInfo = AlertInfo(id: .unknownError) - } - } -} diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModelProtocol.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModelProtocol.swift deleted file mode 100644 index f57ce3432..000000000 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModelProtocol.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Combine - -protocol ServerConfirmationScreenViewModelProtocol { - var actions: AnyPublisher { get } - var context: ServerConfirmationScreenViewModelType.Context { get } -} diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/View/ServerConfirmationScreen.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/View/ServerConfirmationScreen.swift deleted file mode 100644 index 3bd6b3035..000000000 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/View/ServerConfirmationScreen.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Compound -import SwiftUI - -struct ServerConfirmationScreen: View { - @Bindable var context: ServerConfirmationScreenViewModel.Context - - private var backgroundColor: Color { - switch context.viewState.mode { - case .confirmation: .compound.bgCanvasDefault - case .picker: .compound.bgSubtleSecondaryLevel0 - } - } - - private var headerIcon: KeyPath { - switch context.viewState.mode { - case .confirmation: \.userProfileSolid - case .picker: \.homeSolid - } - } - - private var headerIconStyle: BigIcon.Style { - switch context.viewState.mode { - case .confirmation: .defaultSolid - case .picker: .default - } - } - - var body: some View { - FullscreenDialog(topPadding: UIConstants.iconTopPaddingToNavigationBar) { - VStack(spacing: 36) { - header - mainContent - } - } bottomContent: { - buttons - } - .background() - .backgroundStyle(backgroundColor) - .alert(item: $context.alertInfo) - .introspect(.window, on: .supportedVersions) { window in - context.send(viewAction: .updateWindow(window)) - } - } - - /// The main content of the view to be shown in a scroll view. - var header: some View { - VStack(spacing: 8) { - BigIcon(icon: headerIcon, style: headerIconStyle) - .padding(.bottom, 8) - - Text(context.viewState.title) - .font(.compound.headingMDBold) - .multilineTextAlignment(.center) - .foregroundColor(.compound.textPrimary) - .fixedSize(horizontal: false, vertical: true) - - if let message = context.viewState.message { - Text(message) - .font(.compound.bodyMD) - .multilineTextAlignment(.center) - .foregroundColor(.compound.textSecondary) - } - } - .padding(.horizontal, 16) - } - - @ViewBuilder - var mainContent: some View { - if case .picker(let accountProviders) = context.viewState.mode { - FakeInlinePicker(items: accountProviders, - icon: \.host, - selection: $context.pickerSelection) - .accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.serverPicker) - } - } - - /// The action buttons shown at the bottom of the view. - var buttons: some View { - VStack(spacing: 16) { - Button { context.send(viewAction: .confirm) } label: { - Text(L10n.actionContinue) - } - .buttonStyle(.compound(.primary)) - .accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.continue) - - if case .confirmation = context.viewState.mode { - Button { context.send(viewAction: .changeServer) } label: { - Text(L10n.screenServerConfirmationChangeServer) - .font(.compound.bodyLGSemibold) - .padding(14) - } - .accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.changeServer) - } - } - } -} - -/// This is such a hack. I hate it! -/// But… We're not in a List/Form, the compound picker doesn't -/// support icons and this screen's design might change so 🤷‍♂️. -private struct FakeInlinePicker: View { - let items: [String] - let icon: KeyPath - @Binding var selection: String? - - var body: some View { - VStack(spacing: 0) { - ForEach(items, id: \.self) { item in - ListRow(label: .default(title: item, icon: icon), - kind: .selection(isSelected: selection == item) { - selection = item - }) - .overlay(alignment: .bottom) { - if item != items.last { - Divider() - .hidden() - .overlay(Color.compound._borderInteractiveSecondaryAlpha) - .padding(.leading, 54) - } - } - } - } - .clipShape(RoundedRectangle(cornerRadius: 10)) - } -} - -// MARK: - Previews - -struct ServerConfirmationScreen_Previews: PreviewProvider, TestablePreview { - static let loginViewModel = makeViewModel(mode: .confirmation("matrix.org"), flow: .login) - static let registerViewModel = makeViewModel(mode: .confirmation("matrix.org"), flow: .register) - static let pickerViewModel = makeViewModel(mode: .picker(["dept1.company.com", "dept2.company.com", "dept3.company.com"]), flow: .login) - - static var previews: some View { - ElementNavigationStack { - ServerConfirmationScreen(context: loginViewModel.context) - .toolbar(.visible, for: .navigationBar) - } - .previewDisplayName("Login") - - ElementNavigationStack { - ServerConfirmationScreen(context: registerViewModel.context) - .toolbar(.visible, for: .navigationBar) - } - .previewDisplayName("Register") - - ElementNavigationStack { - ServerConfirmationScreen(context: pickerViewModel.context) - .toolbar(.visible, for: .navigationBar) - } - .previewDisplayName("Picker") - } - - static func makeViewModel(mode: ServerConfirmationScreenMode, flow: AuthenticationFlow) -> ServerConfirmationScreenViewModel { - ServerConfirmationScreenViewModel(authenticationService: AuthenticationService.mock, - mode: mode, - authenticationFlow: flow, - appSettings: .volatile(), - userIndicatorController: UserIndicatorControllerMock()) - } -} diff --git a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenCoordinator.swift index d61df55b4..31f940995 100644 --- a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenCoordinator.swift @@ -14,12 +14,13 @@ struct ServerSelectionScreenCoordinatorParameters { let authenticationService: AuthenticationServiceProtocol let authenticationFlow: AuthenticationFlow let appSettings: AppSettings + let homeserverHistoryManager: HomeserverHistoryManager let userIndicatorController: UserIndicatorControllerProtocol } enum ServerSelectionScreenCoordinatorAction { - case updated - case dismiss + case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow) + case continueWithPassword } /// Note: This code was brought over from Riot, we should move the authentication service logic into the view model. @@ -36,9 +37,18 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol { init(parameters: ServerSelectionScreenCoordinatorParameters) { self.parameters = parameters + + let mode: ServerSelectionScreenMode = if parameters.appSettings.allowOtherAccountProviders { + .userInput + } else { + .picker(parameters.appSettings.accountProviders) + } + viewModel = ServerSelectionScreenViewModel(authenticationService: parameters.authenticationService, + mode: mode, authenticationFlow: parameters.authenticationFlow, appSettings: parameters.appSettings, + homeserverHistoryManager: parameters.homeserverHistoryManager, userIndicatorController: parameters.userIndicatorController) } @@ -50,10 +60,10 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { - case .updated: - actionsSubject.send(.updated) - case .dismiss: - actionsSubject.send(.dismiss) + case .continueWithOAuth(let oAuthData, let window): + actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window)) + case .continueWithPassword: + actionsSubject.send(.continueWithPassword) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenModels.swift b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenModels.swift index dec2f392b..e13c9048e 100644 --- a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenModels.swift +++ b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenModels.swift @@ -6,21 +6,71 @@ // Please see LICENSE files in the repository root for full details. // -import Foundation +import SwiftUI enum ServerSelectionScreenViewModelAction { - /// The homeserver selection has been updated. - case updated - /// Dismiss the view without using the entered address. - case dismiss + /// Continue the flow using the provided OAuth parameters. + case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow) + /// Continue the flow using password authentication. + case continueWithPassword +} + +enum ServerSelectionScreenMode: Equatable { + /// The user is confirming the displayed account provider (or can enter their own). + case userInput + /// The user is only allowed to pick from a list of account providers. + case picker([String]) } struct ServerSelectionScreenViewState: BindableState { - /// The message to be shown in the text field footer when no error has occurred. - private let regularFooterMessage = L10n.screenChangeServerFormNotice + /// Whether the screen is configured to confirm a single account provider or pick one from a list of many. + var mode: ServerSelectionScreenMode + /// Whether the screen is configured for registration or existing account login + var authenticationFlow: AuthenticationFlow + /// The presentation anchor used for OAuth authentication. + var window: UIWindow? /// View state that can be bound to from SwiftUI. var bindings: ServerSelectionScreenBindings + + /// Upon introspection, this is the UITextField backing the TextField for server input + var textField: UITextField? { + get { textFieldAdapter.textField } + set { textFieldAdapter.textField = newValue } + } + + /// Adapts the text field for custom functionality beyond what's available in SwiftUI + let textFieldAdapter = TextFieldAdapter() + + var screenTitle: String { + switch authenticationFlow { + case .login: + L10n.screenChangeServerNavigationTitleLogin + case .register: + L10n.screenChangeServerNavigationTitleRegister + } + } + + /// The header text for the screen + var screenHeader: String { + switch authenticationFlow { + case .login: + L10n.screenChangeServerTitleLogin + case .register: + L10n.screenChangeServerTitleRegister + } + } + + /// The message to be shown in the text field footer when no error has occurred. + var regularFooterMessage: String { + switch authenticationFlow { + case .login: + L10n.screenChangeServerTextfieldFooterLogin + case .register: + L10n.screenChangeServerTextfieldFooterRegister + } + } + /// An error message to be shown in the text field footer. var footerErrorMessage: String? @@ -36,22 +86,32 @@ struct ServerSelectionScreenViewState: BindableState { /// Whether it is possible to continue when tapping the confirmation button. var hasValidationError: Bool { - bindings.homeserverAddress.isEmpty || isShowingFooterError + switch mode { + case .userInput: + bindings.homeserverAddress.isEmpty || isShowingFooterError + case .picker(let options): + !options.contains(bindings.homeserverAddress) || isShowingFooterError + } } } struct ServerSelectionScreenBindings { - /// The homeserver address input by the user. + /// The homeserver address input or chosen by the user. var homeserverAddress: String + /// The selection range in `homeserverAddress` + var homeserverSelection: TextSelection? + /// Information describing the currently displayed alert. var alertInfo: AlertInfo? } enum ServerSelectionScreenViewAction { + /// Updates the window used as the OAuth presentation anchor. + case updateWindow(UIWindow) + /// Updates the textfield from introspection + case updateTextField(UITextField) /// The user would like to use the homeserver at the input address. case confirm - /// Dismiss the view without using the entered address. - case dismiss /// Clear any errors shown in the text field footer. case clearFooterError } @@ -67,4 +127,6 @@ enum ServerSelectionScreenErrorType: Hashable { case registrationAlert /// An alert that informs the user that Element Pro should be used for a particular server. case elementProAlert + /// An unknown error has occurred. + case unknownError } diff --git a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift index 550fe4a6d..b56150102 100644 --- a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift @@ -17,31 +17,60 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server private let appSettings: AppSettings private let userIndicatorController: UserIndicatorControllerProtocol + /// Debounces autocompletion from user input + private var debouncedAutocompleteTask: Task? + private var actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } + private let homeserverHistoryManager: HomeserverHistoryManager + init(authenticationService: AuthenticationServiceProtocol, + mode: ServerSelectionScreenMode, authenticationFlow: AuthenticationFlow, appSettings: AppSettings, + homeserverHistoryManager: HomeserverHistoryManager, userIndicatorController: UserIndicatorControllerProtocol) { self.authenticationService = authenticationService self.authenticationFlow = authenticationFlow self.appSettings = appSettings self.userIndicatorController = userIndicatorController + self.homeserverHistoryManager = homeserverHistoryManager - let bindings = ServerSelectionScreenBindings(homeserverAddress: authenticationService.homeserver.value.address) - super.init(initialViewState: ServerSelectionScreenViewState(bindings: bindings)) + let homeserverAddress: String + if case .picker(let providers) = mode { + homeserverAddress = providers[0] + } else { + homeserverAddress = authenticationService.homeserver.value.address + } + let bindings = ServerSelectionScreenBindings(homeserverAddress: homeserverAddress) + super.init(initialViewState: ServerSelectionScreenViewState(mode: mode, authenticationFlow: authenticationFlow, bindings: bindings)) + + context.viewState.textFieldAdapter.keystrokePublisher + .sink { [weak self] in + self?.didUpdateTextFromKeystroke(userProvidedString: $0) + } + .store(in: &cancellables) } override func process(viewAction: ServerSelectionScreenViewAction) { switch viewAction { + case .updateWindow(let window): + guard state.window != window else { return } // stop the infinite loop + state.window = window + case .updateTextField(let textField): + guard state.textField != textField else { return } // stop the infinite loop + state.textField = textField case .confirm: - configureHomeserver() - case .dismiss: - actionsSubject.send(.dismiss) + switch state.mode { + case .userInput: + Task { await configureHomeserver() } + case .picker: + Task { await pickServer() } + } case .clearFooterError: clearFooterError() } @@ -49,22 +78,62 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server // MARK: - Private + private func pickServer() async { + let accountProvider = state.bindings.homeserverAddress + guard accountProvider.isEmpty == false else { + fatalError("It shouldn't be possible to confirm without a selection.") + } + + startLoading() + + switch await authenticationService.configure(for: accountProvider, flow: authenticationFlow) { + case .success: + MXLog.info("Selected server: \(accountProvider)") + await fetchLoginURLIfNeededAndContinue() + stopLoading() + case .failure: + MXLog.info("Invalid server: \(accountProvider)") + stopLoading() + // When the servers are hard-coded they should have a valid configuration, so show a generic error. + state.bindings.alertInfo = AlertInfo(id: .unknownError) + } + } + /// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible. - private func configureHomeserver() { + private func configureHomeserver() async { let homeserverAddress = state.bindings.homeserverAddress startLoading() - Task { - switch await authenticationService.configure(for: homeserverAddress, flow: authenticationFlow) { - case .success: - MXLog.info("Selected homeserver: \(homeserverAddress)") - actionsSubject.send(.updated) - stopLoading() - case .failure(let error): - MXLog.info("Invalid homeserver: \(homeserverAddress)") - stopLoading() - handleError(error) - } + switch await authenticationService.configure(for: homeserverAddress, flow: authenticationFlow) { + case .success: + MXLog.info("Selected homeserver: \(homeserverAddress)") + await fetchLoginURLIfNeededAndContinue() + stopLoading() + case .failure(let error): + MXLog.info("Invalid homeserver: \(homeserverAddress)") + stopLoading() + handleError(error) + } + } + + private func fetchLoginURLIfNeededAndContinue() async { + guard authenticationService.homeserver.value.loginMode.supportsOAuthFlow else { + actionsSubject.send(.continueWithPassword) + return + } + + guard let window = state.window else { + MXLog.error("No window available for OAuth presentation") + showFooterMessage(L10n.errorUnknown) + return + } + + switch await authenticationService.urlForOAuthLogin(loginHint: nil) { + case .success(let oAuthData): + actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window)) + case .failure(let error): + MXLog.error("Failed to get OAuth login URL: \(error)") + showFooterMessage(L10n.errorUnknown) } } @@ -125,4 +194,33 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server guard state.footerErrorMessage != nil else { return } withElementAnimation { state.footerErrorMessage = nil } } + + /// Given the textfield's complete text from the user's latest keystroke, evaluate if it matches any historical + /// server prefixes. If there's a match, update the text value with the complete server, highlighting the portion + /// that was appended. + private func didUpdateTextFromKeystroke(userProvidedString: String) { + let new = userProvidedString + let lowerNew = new.lowercased() + + guard + lowerNew.isEmpty == false, + let expectedMatch = homeserverHistoryManager.server(matchingPrefix: lowerNew), + lowerNew != expectedMatch + else { return } + + let appendage = expectedMatch.dropFirst(new.count) + let newInput = "\(new)\(appendage)" + + let selectionRange = newInput.index(newInput.startIndex, offsetBy: new.count).. + @Binding var selection: String + + var body: some View { + VStack(spacing: 0) { + ForEach(items, id: \.self) { item in + ListRow(label: .default(title: item, icon: icon), + kind: .selection(isSelected: selection == item) { + selection = item + }) + .accessibilityLabel(item) + .accessibilityIdentifier(item) + .overlay(alignment: .bottom) { + if item != items.last { + Divider() + .hidden() + .overlay(Color.compound._borderInteractiveSecondaryAlpha) + .padding(.leading, 54) + } + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + // MARK: - Previews @available(iOS 26.0, *) struct ServerSelection_Previews: PreviewProvider, TestablePreview { - static let matrixViewModel = makeViewModel(for: "https://matrix.org") - static let emptyViewModel = makeViewModel(for: "") - static let invalidViewModel = makeViewModel(for: "thisisbad") + static let matrixViewModel = makeViewModel(mode: .userInput, homeserverAddress: "matrix.org") + static let emptyViewModel = makeViewModel(mode: .userInput, homeserverAddress: "") + static let invalidViewModel = makeViewModel(mode: .userInput, homeserverAddress: "thisisbad") + static let pickerViewModel = makeViewModel(mode: .picker(["matrix.org", "foo.bar", "baz.me"]), homeserverAddress: "foo.bar") static var previews: some View { ElementNavigationStack { ServerSelectionScreen(context: matrixViewModel.context) } + .previewDisplayName("Matrix.org") ElementNavigationStack { ServerSelectionScreen(context: emptyViewModel.context) } + .previewDisplayName("Empty") ElementNavigationStack { ServerSelectionScreen(context: invalidViewModel.context) } .snapshotPreferences(expect: invalidViewModel.context.observe(\.viewState.hasValidationError)) + .previewDisplayName("Error") + + ElementNavigationStack { + ServerSelectionScreen(context: pickerViewModel.context) + } + .previewDisplayName("Picker") } - static func makeViewModel(for homeserverAddress: String) -> ServerSelectionScreenViewModel { + static func makeViewModel(mode: ServerSelectionScreenMode = .userInput, homeserverAddress: String) -> ServerSelectionScreenViewModel { let authenticationService = AuthenticationService.mock + let appSettings = AppSettings.volatile() + let viewModel = ServerSelectionScreenViewModel(authenticationService: authenticationService, + mode: mode, authenticationFlow: .login, - appSettings: .volatile(), + appSettings: appSettings, + homeserverHistoryManager: HomeserverHistoryManager(appSettings: appSettings), userIndicatorController: UserIndicatorControllerMock()) viewModel.context.homeserverAddress = homeserverAddress - if homeserverAddress == "thisisbad" { + if case .userInput = mode, homeserverAddress == "thisisbad" { viewModel.context.send(viewAction: .confirm) } return viewModel diff --git a/ElementX/Sources/Services/Authentication/AuthenticationService.swift b/ElementX/Sources/Services/Authentication/AuthenticationService.swift index abefdd5d6..57497dc32 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationService.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationService.swift @@ -64,7 +64,7 @@ class AuthenticationService: AuthenticationServiceProtocol { } // When updating these, don't forget to update the reset method too. - homeserverSubject = .init(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown)) + homeserverSubject = .init(LoginHomeserver(address: appSettings.defaultServer, loginMode: .unknown)) flow = .login } @@ -240,7 +240,7 @@ class AuthenticationService: AuthenticationServiceProtocol { } func reset() { - homeserverSubject.send(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown)) + homeserverSubject.send(LoginHomeserver(address: appSettings.defaultServer, loginMode: .unknown)) flow = .login client = nil } diff --git a/ElementX/Sources/Services/Client/HomeserverHistoryManager.swift b/ElementX/Sources/Services/Client/HomeserverHistoryManager.swift new file mode 100644 index 000000000..b8a14ac87 --- /dev/null +++ b/ElementX/Sources/Services/Client/HomeserverHistoryManager.swift @@ -0,0 +1,74 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine + +/// Manages the tracking, removal, and autocomplete matchin of previous servers in AppSettings. +/// Servers are sorted by most recently used. +class HomeserverHistoryManager { + let appSettings: AppSettings + + /// cache with confirmed lowercase servers to avoid having to lowercase the entire list on every keystroke + private var cachedServers: [String] = [] + var servers: [String] { + cachedServers + } + + private var cancellables: Set = [] + + init(appSettings: AppSettings) { + self.appSettings = appSettings + + appSettings + .previousServersPublisher + .sink { [weak self] _ in + self?.updateCachedServers() + } + .store(in: &cancellables) + } + + /// Tracks the given server in AppSettings's `previousServers`. The server is asserted + /// unique and placed at the beginning of the list, such that the most recent connected server is at the front. + func addServerToList(_ server: String) { + MXLog.info("Tracking server in previous servers: \(server)") + + let lowercaseServer = server.lowercased() + var newList = _removeServerFromList(lowercaseServer) // don't trigger a publisher update + newList.insert(lowercaseServer, at: 0) + appSettings.previousServers = newList + } + + /// Removes `server` from the tracking history of `AppSettings.previousServers` + func removeServerFromList(_ server: String) { + let lowercaseServer = server.lowercased() + appSettings.previousServers = _removeServerFromList(lowercaseServer) + } + + /// Removes `lowercaseServer` from `appSettings.previousServers` + /// - Parameter lowercaseServer: the server string. It MUST be passed in with all lowercased letters. + /// - Returns: a new copy of the list without `lowercaseServer` + private func _removeServerFromList(_ lowercaseServer: String) -> [String] { + var newList = appSettings.previousServers + newList.removeAll { $0.lowercased() == lowercaseServer } + return newList + } + + /// Retrieves the most recent server matching the given prefix, if any + func server(matchingPrefix prefix: String) -> String? { + let lowercasedPrefix = prefix.lowercased() + return servers.first { $0.hasPrefix(lowercasedPrefix) } + } + + /// Retrieves all servers from AppSettings, previous and pre-provided and stores them in memory + /// in all lowercase. This is the list that matches are made against. + private func updateCachedServers() { + let previous = appSettings.previousServers.map { $0.lowercased() } + let defaultProviders = appSettings.accountProviders.map { $0.lowercased() } + + cachedServers = previous + defaultProviders + } +} diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 1807329e7..0e5bb3de1 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -142,6 +142,7 @@ class MockScreen: Identifiable { let coordinator = ServerSelectionScreenCoordinator(parameters: .init(authenticationService: AuthenticationService.mock, authenticationFlow: .login, appSettings: appSettings, + homeserverHistoryManager: HomeserverHistoryManager(appSettings: appSettings), userIndicatorController: userIndicatorController)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator diff --git a/IntegrationTests/Sources/Common.swift b/IntegrationTests/Sources/Common.swift index a5045b3d8..15d6bd139 100644 --- a/IntegrationTests/Sources/Common.swift +++ b/IntegrationTests/Sources/Common.swift @@ -24,22 +24,10 @@ extension XCUIApplication { getStartedButton.tap(.center) if let homeserver { - let changeHomeserverButton = buttons[A11yIdentifiers.serverConfirmationScreen.changeServer] - XCTAssertTrue(changeHomeserverButton.waitForExistence(timeout: 10.0)) - changeHomeserverButton.tap(.center) - + // Server selection screen appears directly after sign in let homeserverTextField = textFields[A11yIdentifiers.changeServerScreen.server] XCTAssertTrue(homeserverTextField.waitForExistence(timeout: 10.0)) - homeserverTextField.clearAndTypeText(homeserver, app: self) - - let confirmButton = buttons[A11yIdentifiers.changeServerScreen.continue] - XCTAssertTrue(confirmButton.waitForExistence(timeout: 10.0)) - confirmButton.tap(.center) - - // Wait for server confirmation to finish - currentTestCase.expectation(for: doesNotExistPredicate, evaluatedWith: confirmButton) - currentTestCase.waitForExpectations(timeout: 300.0) } let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") @@ -50,7 +38,7 @@ extension XCUIApplication { // Keep looping on the Continue button for ~5 minutes until the Authentication Session is happy. var remainingAttempts = 10 while !webAuthenticationSessionAlertContinueButton.exists { - let continueButton = buttons[A11yIdentifiers.serverConfirmationScreen.continue] + let continueButton = buttons[A11yIdentifiers.changeServerScreen.continue] XCTAssertTrue(continueButton.waitForExistence(timeout: 30.0)) continueButton.tap(.center) diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index a7b45a4c1..1a1f09507 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -1152,13 +1152,6 @@ extension PreviewTests { } } - @Test - func serverConfirmationScreen() async throws { - for (index, preview) in ServerConfirmationScreen_Previews._allPreviews.enumerated() { - try await assertSnapshots(matching: preview, step: index) - } - } - @Test func serverSelection() async throws { for (index, preview) in ServerSelection_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPad-en-GB.png deleted file mode 100644 index ba7b1f7e2..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPad-en-GB.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2f27d379cfab2f1a6051bafb477659476a7cc6b49741aa6324b9a398899743fe -size 102448 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPad-pseudo.png deleted file mode 100644 index 143404803..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPad-pseudo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6b81a350c87e4507538d1164e4abee2b20744cd4d39a1fbe2e11670b964c45c5 -size 119863 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPhone-en-GB.png deleted file mode 100644 index ac1baf161..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPhone-en-GB.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d13b5587f99e70f5af6ffa67dfed0ac1e86463a3ef5945bffe054882741b81ac -size 62912 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPhone-pseudo.png deleted file mode 100644 index 6b84f7020..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Login-iPhone-pseudo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fe55253bdbb21c766fd15417e3f8ab7eeac82f3e6454fec96256eca4c936f611 -size 86722 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPad-en-GB.png deleted file mode 100644 index 5f23671bd..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPad-en-GB.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1a8f9b19e48979489ecd1f85b0d4e269a6c71d1b0914b6bef4edcc5b80e7196b -size 112550 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPad-pseudo.png deleted file mode 100644 index 08bb70bea..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPad-pseudo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:13215b8b562300a91e14cb299a90632f0755a041e7876a0d332ffc1c32459306 -size 114426 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPhone-en-GB.png deleted file mode 100644 index 40310818d..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPhone-en-GB.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f42c0c369f36a86708c5b95cf26aa99c8400cb1810bfe6077395dd8f2d17f0ef -size 60925 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPhone-pseudo.png deleted file mode 100644 index 1cd62d70b..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Picker-iPhone-pseudo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d42afd834e3832731c803923520cc33b869946f38990c57dbf70064ed0b4d691 -size 70052 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPad-en-GB.png deleted file mode 100644 index fc14841a3..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPad-en-GB.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:728f62529a6525f905d3c595b2dc9b5c7d9298decb75c627c404d7acf421718b -size 108154 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPad-pseudo.png deleted file mode 100644 index cc2337488..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPad-pseudo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:00d7ab6cdc91aa264756e850f9ed2eecd54bf21b0bbf91faea6a701b86b020c4 -size 133098 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPhone-en-GB.png deleted file mode 100644 index ca3e30da4..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPhone-en-GB.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4a59fd2dafc033210c6e8e723ab37099b8f7e7f12382d1a0fb2d7c70a4947207 -size 67977 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPhone-pseudo.png deleted file mode 100644 index 9de1a32aa..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverConfirmationScreen.Register-iPhone-pseudo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7911437071e4ec2b83d685a4e89050bc9853bc82944bfaac6da9df662d05d527 -size 97511 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPad-en-GB.png new file mode 100644 index 000000000..e65a84373 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96101e1599fe18d76c4a01fc80134541b81e0c566d064ba8833199aafb4b91b7 +size 110835 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPad-pseudo.png new file mode 100644 index 000000000..8674f3751 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:573dbe543207bceaa0664098968231b0d47d5a975f0f0de08af52616ffc4dffb +size 132562 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPhone-en-GB.png new file mode 100644 index 000000000..1cacf50e8 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56be3f7c9c69e523899f47cbab30d2b3a30d666e3caa191c51ac9ddf113a4492 +size 66474 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPhone-pseudo.png new file mode 100644 index 000000000..c532ac0ce --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Empty-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75b1ca6e6043aad97320515df2e94b1798a0fc8be008115468e01f1ca279988e +size 93280 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPad-en-GB.png new file mode 100644 index 000000000..0d771f914 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6150071c11fd4a59df9f29cf69b7fc19128821ee21818bf41dc5ed01f6eeaa95 +size 118572 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPad-pseudo.png new file mode 100644 index 000000000..227c67afa --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f719fd2b7950d0f3c8449e4ad9a3628313665dae2655da5094fbf336132a6711 +size 150213 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPhone-en-GB.png new file mode 100644 index 000000000..b94c0c9ac --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81bda74f93462654453886ce150c6e8b16c0becd95f1aabd1e10d46b207ec7c1 +size 73302 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPhone-pseudo.png new file mode 100644 index 000000000..5efcda966 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Error-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d7f67046e3d6726fae663582ab78c735bd3fa66aad976659a0dac44c6cbb33b +size 105978 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPad-en-GB.png new file mode 100644 index 000000000..88c8ca25d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da8278c98a491826073bc53650cc627eace25252ba589f05feed4d87dcd9c380 +size 111223 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPad-pseudo.png new file mode 100644 index 000000000..77bce9753 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72a13132ee2a46dafaab55ee15e85478874be00528ebe2810797437f9e07f8cb +size 132790 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPhone-en-GB.png new file mode 100644 index 000000000..1df084cd6 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e90e609769486d289c995b3e957f67b23debc49ca6ebe0becdbd7b4dce736d56 +size 66735 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPhone-pseudo.png new file mode 100644 index 000000000..a0a2303b7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Matrix-org-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57fe8ab5d77b2299024aa4dd32dd85f0d40222ada59832435ca663675109989c +size 92903 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPad-en-GB.png new file mode 100644 index 000000000..389cad15e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc14af1c2cf7495862ce1d7ecba2ec065c193c61af02b8364a1680441541f13e +size 108637 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPad-pseudo.png new file mode 100644 index 000000000..c3b5f1180 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fbcf065f46808bf8baf1d3e1eda5c20cc40d4382c58538b5f139568ab32ffa3 +size 118958 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPhone-en-GB.png new file mode 100644 index 000000000..177794771 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09f9b83ac8a74893bb1a7791dbb47e013c2f0e61e8d467d1dfa76f7ffc162998 +size 56838 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPhone-pseudo.png new file mode 100644 index 000000000..4aa452bad --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.Picker-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:adad286a213181b1f8e8f1a4de6a33a28f6941806d26ae60026c18d09b7d45a0 +size 64974 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-en-GB-0.png deleted file mode 100644 index 157f39bf6..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-en-GB-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0c58d5a2289a4c62ae7910940f5fd10a131ba0673e9b396ee698999342d6e619 -size 105461 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-en-GB-1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-en-GB-1.png deleted file mode 100644 index 887b8c2e9..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-en-GB-1.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fe4e4c9f5cb203297a7a31d83febb449894eca6b2f3001ce934967c5369e42cc -size 102905 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-en-GB-2.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-en-GB-2.png deleted file mode 100644 index 498fd8794..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-en-GB-2.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:575c54730b1b9fbe790c27a17ea6e99e44f122c3caef35e499adbf3ee0363ccc -size 120730 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-pseudo-0.png deleted file mode 100644 index 034b73e3e..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-pseudo-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6f20771a2bc3b8bf82cad88f7e7f77c300923df6e8fed450cec3036916d5f296 -size 116158 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-pseudo-1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-pseudo-1.png deleted file mode 100644 index 3117e3991..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-pseudo-1.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:213e1d081a6faf3964dbcf1808a7f0b07b5b3a410fe6cee7be27216bfba2db31 -size 113696 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-pseudo-2.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-pseudo-2.png deleted file mode 100644 index 6d55d63ce..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPad-pseudo-2.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4fc30b95317161fa9126d0e593f7def4e15733bede5bbe652b5fee710d463354 -size 147912 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-en-GB-0.png deleted file mode 100644 index e33e6a62f..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-en-GB-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f7fcbd78804fac70bfd60f105628b3b76ec39a4e393626122ea710217ed7a358 -size 58588 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-en-GB-1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-en-GB-1.png deleted file mode 100644 index 3b152ed04..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-en-GB-1.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c64d5df3d81e8f53a9041b3880979d703ab4b002fbb723e9046cc93492deb8b1 -size 56263 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-en-GB-2.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-en-GB-2.png deleted file mode 100644 index f4798ddea..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-en-GB-2.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:463ea675e4e84186d654a660ee8c39a3ac96fff0ce260e4ef3ce29f5433d244c -size 79592 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-pseudo-0.png deleted file mode 100644 index b7e6c3879..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-pseudo-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:03b55270586b00290e10584cb7c02f475e84885f3d66c2b76931b5e2a05fc53f -size 72927 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-pseudo-1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-pseudo-1.png deleted file mode 100644 index 9226e3a64..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-pseudo-1.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d8eb226e3906ffa0bde9486dcb72ca0967775e0ed018b0eac6d6bf81b503e862 -size 70674 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-pseudo-2.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-pseudo-2.png deleted file mode 100644 index 55826cc47..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/serverSelection.iPhone-pseudo-2.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:76f660ea1b55fdacae2a6e3858837af29ebdd681d48d6307932a83e163948503 -size 113311 diff --git a/UITests/Sources/AuthenticationFlowCoordinatorTests.swift b/UITests/Sources/AuthenticationFlowCoordinatorTests.swift index 9035b784f..8b1d9fca9 100644 --- a/UITests/Sources/AuthenticationFlowCoordinatorTests.swift +++ b/UITests/Sources/AuthenticationFlowCoordinatorTests.swift @@ -20,18 +20,10 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase { // Splash Screen: Tap get started button app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap() - // Server Confirmation: Tap change server button - app.buttons[A11yIdentifiers.serverConfirmationScreen.changeServer].tap() - - // Server Selection: Clear the default, enter OAuth server and continue. + // Server Selection: Clear the default, enter a server address and submit. + // The \n triggers confirm directly, navigating to the login screen. app.textFields[A11yIdentifiers.changeServerScreen.server].clearAndTypeText("example.com\n", app: app) - // Await for the button to be hittable, since a loader may appear - let serverConfirmationContinueButton = app.buttons[A11yIdentifiers.serverConfirmationScreen.continue] - XCTAssertTrue(serverConfirmationContinueButton.wait(for: \.isHittable, toEqual: true, timeout: 2.0)) - // Server Confirmation: Tap continue button - serverConfirmationContinueButton.tap() - // Login Screen: Wait for continue button to appear let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue] XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0)) @@ -53,18 +45,10 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase { // Splash Screen: Tap get started button app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap() - // Server Confirmation: Tap change server button - app.buttons[A11yIdentifiers.serverConfirmationScreen.changeServer].tap() - - // Server Selection: Clear the default, enter OAuth server and continue. + // Server Selection: Clear the default, enter a server address and submit. + // The \n triggers confirm directly, navigating to the login screen. app.textFields[A11yIdentifiers.changeServerScreen.server].clearAndTypeText("example.com\n", app: app) - // Await for the button to be hittable, since a loader may appear - let serverConfirmationContinueButton = app.buttons[A11yIdentifiers.serverConfirmationScreen.continue] - XCTAssertTrue(serverConfirmationContinueButton.wait(for: \.isHittable, toEqual: true, timeout: 2.0)) - // Server Confirmation: Tap continue button - serverConfirmationContinueButton.tap() - // Login Screen: Wait for continue button to appear let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue] XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0)) @@ -88,18 +72,10 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase { // Splash Screen: Tap get started button app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap() - // Server Confirmation: Tap change server button - app.buttons[A11yIdentifiers.serverConfirmationScreen.changeServer].tap() - - // Server Selection: Clear the default, enter OAuth server and continue. + // Server Selection: Clear the default, enter a server address and submit. + // The \n triggers confirm directly, navigating to the login screen. app.textFields[A11yIdentifiers.changeServerScreen.server].clearAndTypeText("example.com\n", app: app) - // Await for the button to be hittable, since a loader may appear - let serverConfirmationContinueButton = app.buttons[A11yIdentifiers.serverConfirmationScreen.continue] - XCTAssertTrue(serverConfirmationContinueButton.wait(for: \.isHittable, toEqual: true, timeout: 2.0)) - // Server Confirmation: Tap continue button - serverConfirmationContinueButton.tap() - // Login Screen: Wait for continue button to appear let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue] XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0)) @@ -125,9 +101,6 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase { // Splash Screen: Tap get started button app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap() - // Server Confirmation: Tap change server button - app.buttons[A11yIdentifiers.serverConfirmationScreen.changeServer].tap() - // Server Selection: Clear the default, enter OAuth server and continue. app.textFields[A11yIdentifiers.changeServerScreen.server].clearAndTypeText("company.com\n", app: app) @@ -139,8 +112,8 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase { // Keep looping on the Continue button for ~5 minutes until the Authentication Session is happy. var remainingAttempts = 30 while !wasAlertText.exists { - // Server Confirmation: Tap continue button - app.buttons[A11yIdentifiers.serverConfirmationScreen.continue].tap() + // Server Selection: Tap continue button + app.buttons[A11yIdentifiers.changeServerScreen.continue].tap() if wasAlertText.waitForExistence(timeout: 10) { break @@ -223,9 +196,11 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase { // Splash Screen: Tap get started button app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap() - // Server Confirmation: Tap the picker and confirm - app.switches.matching(identifier: A11yIdentifiers.serverConfirmationScreen.serverPicker).element(boundBy: 1).tap() - app.buttons[A11yIdentifiers.serverConfirmationScreen.continue].tap() + // Server Selection: Tap the second server in the picker and confirm. + // Use descendants(matching: .any) since ListRow used outside a List produces an ambiguous + // accessibility element type, making element-type-specific queries unreliable. + app.descendants(matching: .any).matching(identifier: "example.com").firstMatch.tap() + app.buttons[A11yIdentifiers.changeServerScreen.continue].tap() // Login Screen: Wait for continue button to appear let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue] diff --git a/UITests/Sources/__Snapshots__/Application/serverSelection.testEmptyAddress-iPad-en-GB.png b/UITests/Sources/__Snapshots__/Application/serverSelection.testEmptyAddress-iPad-en-GB.png index 0525fba15..c3e6575fe 100644 --- a/UITests/Sources/__Snapshots__/Application/serverSelection.testEmptyAddress-iPad-en-GB.png +++ b/UITests/Sources/__Snapshots__/Application/serverSelection.testEmptyAddress-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26d4cf939e3b6dbb78652c3480a2c329f423bf4f1853e3870d282b78d9a39bf3 -size 166621 +oid sha256:9a1768669c060c14dc99eee7355f7c482417ed3f87040b4a6f9e6ec39de6a54b +size 171208 diff --git a/UITests/Sources/__Snapshots__/Application/serverSelection.testEmptyAddress-iPhone-en-GB.png b/UITests/Sources/__Snapshots__/Application/serverSelection.testEmptyAddress-iPhone-en-GB.png index 439238c77..df7a111af 100644 --- a/UITests/Sources/__Snapshots__/Application/serverSelection.testEmptyAddress-iPhone-en-GB.png +++ b/UITests/Sources/__Snapshots__/Application/serverSelection.testEmptyAddress-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cbcdaaa2d6ddb37e331027c598b5a818ff025b21068dbb0982e3b7db2e1a5171 -size 170739 +oid sha256:a9048c7635c8e6d54a6f9cc0641fef1a7ef8d47bffbadbcff383221292dfc7a3 +size 178955 diff --git a/UITests/Sources/__Snapshots__/Application/serverSelection.testInvalidAddress-iPad-en-GB.png b/UITests/Sources/__Snapshots__/Application/serverSelection.testInvalidAddress-iPad-en-GB.png index 755cab990..d7440b0c8 100644 --- a/UITests/Sources/__Snapshots__/Application/serverSelection.testInvalidAddress-iPad-en-GB.png +++ b/UITests/Sources/__Snapshots__/Application/serverSelection.testInvalidAddress-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab299696c4358f35d57b48a3dad5d783c1776e23e81b67e915c69e1fca021dc5 -size 115595 +oid sha256:32ab691123f12169747b3ee8fa26ec6cea48478aa87c607b40de6f7665cd6e07 +size 107836 diff --git a/UITests/Sources/__Snapshots__/Application/serverSelection.testInvalidAddress-iPhone-en-GB.png b/UITests/Sources/__Snapshots__/Application/serverSelection.testInvalidAddress-iPhone-en-GB.png index e22dcaab2..850146905 100644 --- a/UITests/Sources/__Snapshots__/Application/serverSelection.testInvalidAddress-iPhone-en-GB.png +++ b/UITests/Sources/__Snapshots__/Application/serverSelection.testInvalidAddress-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4ee7497137b730cc1e62740ccf7d90756002ac9f72e53de9251f0be070af28d -size 133905 +oid sha256:c30d4677ac36d40b6cf1fd654c1ee13ffdad5533a5d49dac68c62e94b951da2d +size 119739 diff --git a/UITests/Sources/__Snapshots__/Application/serverSelection.testNormalState-iPad-en-GB.png b/UITests/Sources/__Snapshots__/Application/serverSelection.testNormalState-iPad-en-GB.png index 5de15b64a..edabdd1d0 100644 --- a/UITests/Sources/__Snapshots__/Application/serverSelection.testNormalState-iPad-en-GB.png +++ b/UITests/Sources/__Snapshots__/Application/serverSelection.testNormalState-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e59419f59671ec5fdaf2bc817e29456ef318de0e89f574b087a92b9437a15d56 -size 96869 +oid sha256:90df1f4842b0f49551d39016fcb33a95a910a92655f6842a116443a78d4caf61 +size 101227 diff --git a/UITests/Sources/__Snapshots__/Application/serverSelection.testNormalState-iPhone-en-GB.png b/UITests/Sources/__Snapshots__/Application/serverSelection.testNormalState-iPhone-en-GB.png index e9e47e52e..971f0e189 100644 --- a/UITests/Sources/__Snapshots__/Application/serverSelection.testNormalState-iPhone-en-GB.png +++ b/UITests/Sources/__Snapshots__/Application/serverSelection.testNormalState-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:785e95a2df0dbf82654d9d10007ee211e4532f9e6f72130b8d361dcb50206876 -size 105940 +oid sha256:43cb650082cb249d296df4cd72dc1859eaf4a3a6d2f6ccf1e92d0984cab52191 +size 109976 diff --git a/UnitTests/Sources/HomeserverHistoryManagerTests.swift b/UnitTests/Sources/HomeserverHistoryManagerTests.swift new file mode 100644 index 000000000..c86ae4719 --- /dev/null +++ b/UnitTests/Sources/HomeserverHistoryManagerTests.swift @@ -0,0 +1,134 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +@testable import ElementX +import Testing + +@MainActor +struct HomeserverHistoryManagerTests { + @Test + func matchExistingServer() { + // given a configured manager + let manager = createManager(withServerHistory: [ + "matrix.org", + "example.com" + ]) + + // when querying a value that matches something in the list + // then it returns that value without case sensitiveity + #expect(manager.server(matchingPrefix: "ma") == "matrix.org") + #expect(manager.server(matchingPrefix: "Ma") == "matrix.org") + } + + @Test + func matchExistingServerinLRUOrder() { + // given a configured manager with multiple values with similar prefixes + let manager = createManager(withServerHistory: [ + "matrix.org", + "mandibles.chomp" + ]) + + // when querying the common portions of the prefix + // then the result is the most recent addition to the list + #expect(manager.server(matchingPrefix: "m") == "matrix.org") + #expect(manager.server(matchingPrefix: "ma") == "matrix.org") + #expect(manager.server(matchingPrefix: "man") == "mandibles.chomp") + } + + @Test + func serversAreAddedInLRUOrder() { + // given a configured manager + let manager = createManager(withServerHistory: [ + "matrix.org", + "mandibles.chomp" + ]) + + // when adding a new server + manager.addServerToList("merch.com") + + // then querying gives priority to the latest addition + #expect(manager.server(matchingPrefix: "m") == "merch.com") + #expect(manager.server(matchingPrefix: "ma") == "matrix.org") + #expect(manager.server(matchingPrefix: "man") == "mandibles.chomp") + } + + @Test + func serversAreUpdatedInLRUOrder() { + // given a configured manager + let manager = createManager(withServerHistory: [ + "matrix.org", + "mandibles.chomp", + "merch.com" + ]) + + // then querying has its given priority + #expect(manager.server(matchingPrefix: "m") == "matrix.org") + #expect(manager.server(matchingPrefix: "me") == "merch.com") + #expect(manager.server(matchingPrefix: "ma") == "matrix.org") + #expect(manager.server(matchingPrefix: "man") == "mandibles.chomp") + + // when adding an existing server (case insensitive) + manager.addServerToList("MERCH.com") + + // then querying is updated to give the latest prioity + #expect(manager.server(matchingPrefix: "m") == "merch.com") + #expect(manager.server(matchingPrefix: "me") == "merch.com") + #expect(manager.server(matchingPrefix: "ma") == "matrix.org") + #expect(manager.server(matchingPrefix: "man") == "mandibles.chomp") + } + + @Test func matchNothingOnMiss() { + // given a configured manager + let manager = createManager(withServerHistory: [ + "matrix.org", + "example.com" + ]) + + // when making a query that matches nothing in its list, then there are no matches + #expect(manager.server(matchingPrefix: "mi") == nil) + #expect(manager.server(matchingPrefix: "g") == nil) + } + + @Test func additionalServerIncludedInResults() { + // given a configured manager + let manager = createManager(withServerHistory: [ + "matrix.org", + "example.com" + ]) + #expect(manager.server(matchingPrefix: "f") == nil) + + // when adding a new server + manager.addServerToList("FOO.bar") + + // then it will become available with subsequent matching queries + #expect(manager.server(matchingPrefix: "f") == "foo.bar") + #expect(manager.server(matchingPrefix: "F") == "foo.bar") + } + + @Test func removingServerIsGone() { + // given a configured manager + let manager = createManager(withServerHistory: [ + "matrix.org", + "example.com" + ]) + + // when removing a server + manager.removeServerFromList("example.com") + + // then it is no longer available in query results + #expect(manager.server(matchingPrefix: "e") == nil) + // and doesn't affect the other server entries + #expect(manager.server(matchingPrefix: "m") == "matrix.org") + } + + private func createManager(withServerHistory history: [String]) -> HomeserverHistoryManager { + let appSettings = AppSettings.volatile() + appSettings.previousServers = history + + return HomeserverHistoryManager(appSettings: appSettings) + } +} diff --git a/UnitTests/Sources/ServerConfigurationScreenViewStateTests.swift b/UnitTests/Sources/ServerConfigurationScreenViewStateTests.swift deleted file mode 100644 index 9da2ef13e..000000000 --- a/UnitTests/Sources/ServerConfigurationScreenViewStateTests.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2023-2025 New Vector 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 Testing - -@MainActor -struct ServerConfirmationScreenViewStateTests { - @Test - func loginMessageString() { - let matrixDotOrgLogin = ServerConfirmationScreenViewState(mode: .confirmation(LoginHomeserver.mockMatrixDotOrg.address), - authenticationFlow: .login) - #expect(matrixDotOrgLogin.message == L10n.screenServerConfirmationMessageLoginMatrixDotOrg, "matrix.org should have a custom message.") - - let elementDotIoLogin = ServerConfirmationScreenViewState(mode: .confirmation("element.io"), - authenticationFlow: .login) - #expect(elementDotIoLogin.message == L10n.screenServerConfirmationMessageLoginElementDotIo, "element.io should have a custom message.") - - let otherLogin = ServerConfirmationScreenViewState(mode: .confirmation(LoginHomeserver.mockOAuth.address), - authenticationFlow: .login) - #expect(otherLogin.message == "", "Other servers should not show a message.") - - let pickerLogin = ServerConfirmationScreenViewState(mode: .picker(["element.io", "matrix.org"]), - authenticationFlow: .login) - #expect(pickerLogin.message == nil, "The picker mode should not show a message.") - } - - @Test - func registerMessageString() { - let matrixDotOrgRegister = ServerConfirmationScreenViewState(mode: .confirmation(LoginHomeserver.mockMatrixDotOrg.address), - authenticationFlow: .register) - #expect(matrixDotOrgRegister.message == L10n.screenServerConfirmationMessageRegister, "The registration message should always be the same.") - - let oAuthRegister = ServerConfirmationScreenViewState(mode: .confirmation(LoginHomeserver.mockOAuth.address), - authenticationFlow: .register) - #expect(oAuthRegister.message == L10n.screenServerConfirmationMessageRegister, "The registration message should always be the same.") - } -} diff --git a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift deleted file mode 100644 index d3a798872..000000000 --- a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift +++ /dev/null @@ -1,398 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector 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 MatrixRustSDKMocks -import SwiftUI -import Testing - -@MainActor -final class ServerConfirmationScreenViewModelTests { - var clientFactory: AuthenticationClientFactoryMock! - var client: ClientSDKMock! - var service: AuthenticationServiceProtocol! - - var viewModel: ServerConfirmationScreenViewModel! - var context: ServerConfirmationScreenViewModel.Context { - viewModel.context - } - - private let appSettings: AppSettings - - init() { - appSettings = AppSettings.volatile() - } - - // MARK: - Confirmation mode - - @Test - func confirmLoginWithoutConfiguration() async throws { - // Given a view model for login using a service that hasn't been configured. - setupViewModel(authenticationFlow: .login) - #expect(service.homeserver.value.loginMode == .unknown) - #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then a call to configure service should be made. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent) - #expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true)) - } - - @Test - func confirmLoginAfterConfiguration() async throws { - // Given a view model for login using a service that has already been configured (via the server selection screen). - setupViewModel(authenticationFlow: .login) - guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else { - Issue.record("The configuration should succeed.") - return - } - #expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true)) - #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then the configured homeserver should be used and no additional client should be built. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent) - } - - @Test - func confirmRegisterWithoutConfiguration() async throws { - // Given a view model for registration using a service that hasn't been configured. - setupViewModel(authenticationFlow: .register) - #expect(service.homeserver.value.loginMode == .unknown) - #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then a call to configure service should be made. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) - // The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429 - // #expect(client.urlForOauthOauthConfigurationPromptReceivedArguments?.prompt == .create) - #expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true)) - } - - @Test - func confirmRegisterAfterConfiguration() async throws { - // Given a view model for registration using a service that has already been configured (via the server selection screen). - setupViewModel(authenticationFlow: .register) - guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .register) else { - Issue.record("The configuration should succeed.") - return - } - #expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true)) - #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then the configured homeserver should be used and no additional client should be built. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - // The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429 - // #expect(client.urlForOauthOauthConfigurationPromptReceivedArguments?.prompt == .create) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) - } - - @Test - func confirmPasswordLoginWithoutConfiguration() async throws { - // Given a view model for login using a service that hasn't been configured (against a server that doesn't support OAuth). - setupViewModel(authenticationFlow: .login, supportsOAuth: false) - #expect(service.homeserver.value.loginMode == .unknown) - #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then a call to configure service should be made, but not for the OAuth URL. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - #expect(service.homeserver.value.loginMode == .password) - } - - @Test - func confirmPasswordLoginAfterConfiguration() async throws { - // Given a view model for login using a service that has already been configured (via the server selection screen). - setupViewModel(authenticationFlow: .login, supportsOAuth: false) - guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else { - Issue.record("The configuration should succeed.") - return - } - #expect(service.homeserver.value.loginMode == .password) - #expect(context.viewState.mode == .confirmation(service.homeserver.value.address)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then the configured homeserver should be used and no additional client should be built, nor a call to get the OAuth URL. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - } - - @Test - func registrationNotSupportedAlert() async throws { - // Given a view model for registration using a service that hasn't been configured and the default server doesn't support registration. - // Note: We don't currently take the create prompt into account when determining registration support. - setupViewModel(authenticationFlow: .register, supportsOAuth: false, supportsOAuthCreatePrompt: false) - #expect(service.homeserver.value.loginMode == .unknown) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) - #expect(context.alertInfo == nil) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then the configuration should fail with an alert about not supporting registration. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(context.alertInfo?.id == .registration) - } - - @Test - func loginNotSupportedAlert() async throws { - // Given a view model for login using a service that hasn't been configured and the default server doesn't support login. - setupViewModel(authenticationFlow: .login, supportsOAuth: false, supportsOAuthCreatePrompt: false, supportsPasswordLogin: false) - #expect(service.homeserver.value.loginMode == .unknown) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) - #expect(context.alertInfo == nil) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then the configuration should fail with an alert about not supporting login. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(context.alertInfo?.id == .login) - } - - @Test - func elementProRequired() async throws { - // Given a view model for login using a service that hasn't been configured and the default server requires Element Pro. - setupViewModel(authenticationFlow: .login, supportsOAuth: false, supportsOAuthCreatePrompt: false, supportsPasswordLogin: false, requiresElementPro: true) - #expect(service.homeserver.value.loginMode == .unknown) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) - #expect(context.alertInfo == nil) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then the configuration should fail with an alert telling the user to download Element Pro. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(context.alertInfo?.id == .elementProRequired(serverName: "matrix.org")) - } - - // MARK: - Picker mode - - @Test - func pickerWithoutConfiguration() async throws { - // Given a view model for login using a service that hasn't been configured. - setupViewModel(authenticationFlow: .login, restrictedFlow: true) - #expect(service.homeserver.value.loginMode == .unknown) - #expect(context.viewState.mode == .picker(appSettings.accountProviders)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then a call to configure service should be made. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent) - #expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true)) - } - - @Test - func pickerAfterConfiguration() async throws { - // Given a view model for login using a service that has already been configured (via the server selection screen). - setupViewModel(authenticationFlow: .login, restrictedFlow: true) - guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else { - Issue.record("The configuration should succeed.") - return - } - #expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true)) - #expect(context.viewState.mode == .picker(appSettings.accountProviders)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then the configured homeserver should be used and no additional client should be built. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent) - } - - @Test - func pickerForPasswordLoginWithoutConfiguration() async throws { - // Given a view model for login using a service that hasn't been configured (against a server that doesn't support OAuth). - setupViewModel(authenticationFlow: .login, supportsOAuth: false, restrictedFlow: true) - #expect(service.homeserver.value.loginMode == .unknown) - #expect(context.viewState.mode == .picker(appSettings.accountProviders)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then a call to configure service should be made, but not for the OAuth URL. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - #expect(service.homeserver.value.loginMode == .password) - } - - @Test - func pickerForPasswordLoginAfterConfiguration() async throws { - // Given a view model for login using a service that has already been configured (via the server selection screen). - setupViewModel(authenticationFlow: .login, supportsOAuth: false, restrictedFlow: true) - guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else { - Issue.record("The configuration should succeed.") - return - } - #expect(service.homeserver.value.loginMode == .password) - #expect(context.viewState.mode == .picker(appSettings.accountProviders)) - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - - // When continuing from the confirmation screen. - let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } - context.send(viewAction: .confirm) - try await deferred.fulfill() - - // Then the configured homeserver should be used and no additional client should be built, nor a call to get the OAuth URL. - #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) - #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) - } - - // MARK: - Helpers - - private func setupViewModel(authenticationFlow: AuthenticationFlow, - supportsOAuth: Bool = true, - supportsOAuthCreatePrompt: Bool = true, - supportsPasswordLogin: Bool = true, - restrictedFlow: Bool = false, - requiresElementPro: Bool = false) { - var mode = ServerConfirmationScreenMode.confirmation("matrix.org") - if restrictedFlow { - appSettings.override(accountProviders: ["matrix.org", "beta.matrix.org"], - allowOtherAccountProviders: false, - hideBrandChrome: false, - pushGatewayBaseURL: appSettings.pushGatewayBaseURL, - oAuthRedirectURL: appSettings.oAuthRedirectURL, - oAuthClientURIPath: appSettings.oAuthClientURIPath, - websiteURL: appSettings.websiteURL, - logoURL: appSettings.logoURL, - copyrightURL: appSettings.copyrightURL, - acceptableUseURL: appSettings.acceptableUseURL, - privacyURL: appSettings.privacyURL, - encryptionURL: appSettings.encryptionURL, - deviceVerificationURL: appSettings.deviceVerificationURL, - chatBackupDetailsURL: appSettings.chatBackupDetailsURL, - identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL, - historySharingDetailsURL: appSettings.historySharingDetailsURL, - elementWebHosts: appSettings.elementWebHosts, - accountProvisioningHost: appSettings.accountProvisioningHost, - bugReportApplicationID: appSettings.bugReportApplicationID, - analyticsTermsURL: appSettings.analyticsTermsURL, - mapTilerConfiguration: AppSettings.bundledMapTilerConfiguration) - mode = .picker(appSettings.accountProviders) - } - - // Manually create a configuration as the default homeserver address setting is immutable. - client = ClientSDKMock(.init(oAuthLoginURL: supportsOAuth ? "https://account.matrix.org/authorize" : nil, - supportsOAuthCreatePrompt: supportsOAuthCreatePrompt, - supportsPasswordLogin: supportsPasswordLogin, - elementWellKnown: requiresElementPro ? "{\"version\":1,\"enforce_element_pro\":true}" : nil)) - let configuration = AuthenticationClientFactoryMock.Configuration(homeserverClients: ["matrix.org": client]) - - clientFactory = AuthenticationClientFactoryMock(configuration) - service = AuthenticationService(userSessionStore: UserSessionStoreMock(.init()), - encryptionKeyProvider: EncryptionKeyProvider(), - classicAppManager: nil, - clientFactory: clientFactory, - appSettings: appSettings, - appHooks: AppHooks()) - - viewModel = ServerConfirmationScreenViewModel(authenticationService: service, - mode: mode, - authenticationFlow: authenticationFlow, - appSettings: appSettings, - userIndicatorController: UserIndicatorControllerMock()) - - // Add a fake window in order for the OAuth flow to continue - viewModel.context.send(viewAction: .updateWindow(UIWindow())) - } -} - -private extension ServerConfirmationScreenViewState { - var homeserverAddress: String { - switch mode { - case .confirmation(let accountProvider): - accountProvider - case .picker: - fatalError() - } - } -} - -private extension ServerConfirmationScreenViewModelAction { - var isContinueWithOAuth: Bool { - switch self { - case .continueWithOAuth: true - default: false - } - } - - var isContinueWithPassword: Bool { - switch self { - case .continueWithPassword: true - default: false - } - } -} diff --git a/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift index f93a32950..574ca9a04 100644 --- a/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift +++ b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift @@ -7,10 +7,17 @@ // @testable import ElementX +import MatrixRustSDKMocks +import SwiftUI import Testing @MainActor struct ServerSelectionScreenViewModelTests { + private static let passwordOnlyServer = "example.com" // password only, no OAuth + private static let pickerProviders = ["matrix.org", "beta.matrix.org", passwordOnlyServer] + + var appSettings: AppSettings! + var client: ClientSDKMock! var clientFactory: AuthenticationClientFactoryMock! var service: AuthenticationServiceProtocol! var viewModel: ServerSelectionScreenViewModelProtocol! @@ -22,13 +29,13 @@ struct ServerSelectionScreenViewModelTests { @Test mutating func selectForLogin() async throws { // Given a view model for login. - setup(authenticationFlow: .login) + try setup(authenticationFlow: .login) #expect(service.homeserver.value.loginMode == .unknown) #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) // When selecting matrix.org. context.homeserverAddress = "matrix.org" - let deferred = deferFulfillment(viewModel.actions) { $0 == .updated } + let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth } context.send(viewAction: .confirm) try await deferred.fulfill() @@ -40,7 +47,7 @@ struct ServerSelectionScreenViewModelTests { @Test mutating func loginNotSupportedAlert() async throws { // Given a view model for login. - setup(authenticationFlow: .login) + try setup(authenticationFlow: .login) #expect(service.homeserver.value.loginMode == .unknown) #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) #expect(context.alertInfo == nil) @@ -59,13 +66,13 @@ struct ServerSelectionScreenViewModelTests { @Test mutating func selectForRegistration() async throws { // Given a view model for registration. - setup(authenticationFlow: .register) + try setup(authenticationFlow: .register) #expect(service.homeserver.value.loginMode == .unknown) #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) // When selecting matrix.org. context.homeserverAddress = "matrix.org" - let deferred = deferFulfillment(viewModel.actions) { $0 == .updated } + let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth } context.send(viewAction: .confirm) try await deferred.fulfill() @@ -77,7 +84,7 @@ struct ServerSelectionScreenViewModelTests { @Test mutating func registrationNotSupportedAlert() async throws { // Given a view model for registration. - setup(authenticationFlow: .register) + try setup(authenticationFlow: .register) #expect(service.homeserver.value.loginMode == .unknown) #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) #expect(context.alertInfo == nil) @@ -96,7 +103,7 @@ struct ServerSelectionScreenViewModelTests { @Test mutating func elementProRequiredAlert() async throws { // Given a view model for login. - setup(authenticationFlow: .login) + try setup(authenticationFlow: .login) #expect(service.homeserver.value.loginMode == .unknown) #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) #expect(context.alertInfo == nil) @@ -115,10 +122,10 @@ struct ServerSelectionScreenViewModelTests { @Test mutating func invalidServer() async throws { // Given a new instance of the view model. - setup(authenticationFlow: .login) + try setup(authenticationFlow: .login) #expect(!context.viewState.isShowingFooterError, "There should not be an error message for a new view model.") #expect(context.viewState.footerErrorMessage == nil, "There should not be an error message for a new view model.") - #expect(String(context.viewState.footerMessage) == L10n.screenChangeServerFormNotice, + #expect(String(context.viewState.footerMessage) == L10n.screenChangeServerTextfieldFooterLogin, "The standard footer message should be shown.") // When attempting to discover an invalid server @@ -130,7 +137,7 @@ struct ServerSelectionScreenViewModelTests { // Then the footer should now be showing an error. #expect(context.viewState.isShowingFooterError, "The error message should be stored.") #expect(context.viewState.footerErrorMessage != nil, "The error message should be stored.") - #expect(String(context.viewState.footerMessage) != L10n.screenChangeServerFormNotice, + #expect(String(context.viewState.footerMessage) != L10n.screenChangeServerTextfieldFooterLogin, "The error message should be shown.") // And when clearing the error. @@ -141,16 +148,204 @@ struct ServerSelectionScreenViewModelTests { // Then the error message should now be removed. #expect(context.viewState.footerErrorMessage == nil, "The error message should have been cleared.") - #expect(String(context.viewState.footerMessage) == L10n.screenChangeServerFormNotice, + #expect(String(context.viewState.footerMessage) == L10n.screenChangeServerTextfieldFooterLogin, "The standard footer message should be shown again.") } + // MARK: - userInput mode + + @Test + mutating func userInputPasswordLoginWithoutConfiguration() async throws { + // Given a view model for login using a service that hasn't been configured against a server that doesn't support OAuth. + try setup(authenticationFlow: .login) + context.homeserverAddress = Self.passwordOnlyServer + #expect(service.homeserver.value.loginMode == .unknown) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + + // When confirming from the server selection screen. + let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } + context.send(viewAction: .confirm) + try await deferred.fulfill() + + // Then the service should be configured but no OAuth URL fetched. + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(service.homeserver.value.loginMode == .password) + } + + @Test + mutating func userInputPasswordLoginAfterConfiguration() async throws { + // Given a view model for login using a service that has already been configured against a server that doesn't support OAuth. + try setup(authenticationFlow: .login) + context.homeserverAddress = Self.passwordOnlyServer + guard case .success = await service.configure(for: context.homeserverAddress, flow: .login) else { + Issue.record("The configuration should succeed.") + return + } + #expect(service.homeserver.value.loginMode == .password) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + + // When confirming from the server selection screen. + let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } + context.send(viewAction: .confirm) + try await deferred.fulfill() + + // Then the service should be re-configured but no OAuth URL fetched. + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2) + } + + // MARK: - Picker mode + + @Test + mutating func pickerWithoutConfiguration() async throws { + // Given a view model for login using a service that hasn't been configured. + try setup(authenticationFlow: .login, mode: .picker(Self.pickerProviders)) + #expect(service.homeserver.value.loginMode == .unknown) + #expect(context.viewState.mode == .picker(Self.pickerProviders)) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) + + // When confirming from the picker. + let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth } + context.send(viewAction: .confirm) + try await deferred.fulfill() + + // Then the service should be configured and the OAuth URL fetched. + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1) + #expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent) + #expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true)) + } + + @Test + mutating func pickerForPasswordLoginWithoutConfiguration() async throws { + // Given a view model for login using a service that hasn't been configured against a server that doesn't support OAuth. + try setup(authenticationFlow: .login, mode: .picker(Self.pickerProviders)) + context.homeserverAddress = Self.passwordOnlyServer + #expect(service.homeserver.value.loginMode == .unknown) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0) + + // When confirming from the picker. + let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } + context.send(viewAction: .confirm) + try await deferred.fulfill() + + // Then the service should be configured but no OAuth URL fetched. + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(service.homeserver.value.loginMode == .password) + } + + // MARK: - Autocomplete + + @Test + mutating func autocompleteMatchesPreviousServers() async throws { + // Given a view model with a previous server in history. + try setup(authenticationFlow: .login) + appSettings.previousServers = ["myserver.com"] + context.homeserverAddress = "" + let textField = UITextField() + context.send(viewAction: .updateTextField(textField)) + + // When the user types a prefix that matches the previous server. + let typedSoFar = "my" + let nextCharacter = "s" + let typed = typedSoFar + nextCharacter + let expectedAddress = "myserver.com" + textField.text = typedSoFar + let deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == expectedAddress } + _ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: typedSoFar.count, length: 0), replacementString: nextCharacter) + try await deferred.fulfill() + + // Then the address should be completed and the appended portion selected. + let selectionStart = expectedAddress.index(expectedAddress.startIndex, offsetBy: typed.count) + #expect(context.homeserverAddress == expectedAddress) + #expect(context.homeserverSelection == TextSelection(range: selectionStart..