diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index fc5cba257..c4c8ffe5a 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -144,7 +144,6 @@ 1D623953F970D11F6F38499C /* AppLockService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851B95BB98649B8E773D6790 /* AppLockService.swift */; }; 1D69E31913DF66426985909B /* EmojiPickerScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11151E78D6BB2B04A8FBD389 /* EmojiPickerScreenViewModelProtocol.swift */; }; 1DC227816777A2F3A19657E5 /* RoomDirectorySearchScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF71646898A2F720C5BFDF5 /* RoomDirectorySearchScreenViewModel.swift */; }; - 1E59B77A0B2CE83DCC1B203C /* LoginViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05707BF550D770168A406DB /* LoginViewModelTests.swift */; }; 1F3232BD368DF430AB433907 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = 07FEEEDB11543A7DED420F04 /* Compound */; }; 1FE593ECEC40A43789105D80 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */; }; 1FEC0A4EC6E6DF693C16B32A /* StringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */; }; @@ -463,6 +462,7 @@ 67C05C50AD734283374605E3 /* MatrixEntityRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */; }; 67D6E0700A9C1E676F6231F8 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = AD544C0FA48DFFB080920061 /* Collections */; }; 67E9926C4572C54F59FCA91A /* AuthenticationFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B069D7772DDF6513E0F1B8 /* AuthenticationFlowCoordinator.swift */; }; + 67ECD32538F6DAFE38A623F9 /* ServerSelectionScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E45EBAFF1A83538D54ABDF92 /* ServerSelectionScreenViewModelTests.swift */; }; 67EFF46180B939CBF389AECD /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C713D124FE915ABF47A6B7 /* TimelineView.swift */; }; 6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73587C2E3CF5998361AE516 /* HomeScreenRoomTests.swift */; }; 68184EF36396424FE19A727D /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; }; @@ -513,6 +513,7 @@ 73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; }; 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; }; 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; }; + 7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */; }; 743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */; }; 748F482FEF4E04D61C39AAD7 /* EmojiPickerScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */; }; 7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */; }; @@ -668,7 +669,6 @@ 92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */; }; 934051B17A884AB0635DF81B /* BlockedUsersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */; }; 937985546F708339711ECDFC /* ComposerToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85666E40F7E817809B4FD787 /* ComposerToolbar.swift */; }; - 93875ADD456142D20823ED24 /* ServerSelectionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */; }; 93A549135E6C027A0D823BFE /* DTCoreText in Frameworks */ = {isa = PBXBuildFile; productRef = 593FBBF394712F2963E98A0B /* DTCoreText */; }; 93AC1E8418D8C827671FB3A9 /* IdentityConfirmedScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 595EC503DA5517BBE6D39406 /* IdentityConfirmedScreenCoordinator.swift */; }; 93BA4A81B6D893271101F9F0 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = A7CA6F33C553805035C3B114 /* DeviceKit */; }; @@ -1607,6 +1607,7 @@ 5A1119E9C63AE530252640D2 /* SecureBackupController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupController.swift; sourceTree = ""; }; 5A2FCA3D0F239B9E911B966B /* SecureBackupRecoveryKeyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupRecoveryKeyScreen.swift; sourceTree = ""; }; 5A37E2FACFD041CE466223CD /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModelTests.swift; sourceTree = ""; }; 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenCoordinator.swift; sourceTree = ""; }; 5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = ""; }; 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = ""; }; @@ -1888,7 +1889,6 @@ A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModel.swift; sourceTree = ""; }; A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModel.swift; sourceTree = ""; }; A02D1A490944BF01A37586E1 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/SAS.strings; sourceTree = ""; }; - A05707BF550D770168A406DB /* LoginViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModelTests.swift; sourceTree = ""; }; A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = ""; }; A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerProtocol.swift; sourceTree = ""; }; A130A2251A15A7AACC84FD37 /* RoomPollsHistoryScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -2186,6 +2186,7 @@ E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModel.swift; sourceTree = ""; }; E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorModalView.swift; sourceTree = ""; }; E44E35AA87F49503E7B3BF6E /* AudioConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverter.swift; sourceTree = ""; }; + E45EBAFF1A83538D54ABDF92 /* ServerSelectionScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModelTests.swift; sourceTree = ""; }; E461B3C8BBBFCA400B268D14 /* AppRouteURLParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteURLParserTests.swift; sourceTree = ""; }; E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; E53BFB7E4F329621C844E8C3 /* AnalyticsPromptScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreen.swift; sourceTree = ""; }; @@ -2233,7 +2234,6 @@ ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; ED49073BB1C1FC649DAC2CCD /* LocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineView.swift; sourceTree = ""; }; ED60E4D2CD678E1EBF16F77A /* BlockedUsersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreen.swift; sourceTree = ""; }; - EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItemContent.swift; sourceTree = ""; }; EE6BFF453838CF6C3982C5A3 /* RoomDirectorySearchScreenScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreenScreenViewModelTests.swift; sourceTree = ""; }; EEAA2832D93EC7D2608703FB /* NSEUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEUserSession.swift; sourceTree = ""; }; @@ -3828,7 +3828,7 @@ 6E5725BC6C63604CB769145B /* LegalInformationScreenViewModelTests.swift */, C070FD43DC6BF4E50217965A /* LocalizationTests.swift */, 3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */, - A05707BF550D770168A406DB /* LoginViewModelTests.swift */, + 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */, 376D941BF8BB294389C0DE24 /* MapTilerURLBuildersTests.swift */, F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */, 2D7A2C4A3A74F0D2FFE9356A /* MediaPlayerProviderTests.swift */, @@ -3870,7 +3870,7 @@ 40316EFFEAC7B206EE9A55AE /* SecureBackupScreenViewModelTests.swift */, 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */, F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */, - EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */, + E45EBAFF1A83538D54ABDF92 /* ServerSelectionScreenViewModelTests.swift */, 0825EAFD47332DD459DE893F /* SessionDirectoriesTests.swift */, A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */, DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */, @@ -6122,7 +6122,7 @@ 8AC256AF0EC54658321C9241 /* LegalInformationScreenViewModelTests.swift in Sources */, 0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */, 149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */, - 1E59B77A0B2CE83DCC1B203C /* LoginViewModelTests.swift in Sources */, + 7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */, 77C1A2F49CD90D3EFDF376E5 /* MapTilerURLBuildersTests.swift in Sources */, 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */, 4B978C09567387EF4366BD7A /* MediaLoaderTests.swift in Sources */, @@ -6171,7 +6171,7 @@ 1B8E30B35BF8F541C1318F19 /* SecureBackupScreenViewModelTests.swift in Sources */, 53A55748D5F587C9061F98BF /* ServerConfigurationScreenViewStateTests.swift in Sources */, 89658A44C9FC19B58FD1C226 /* ServerConfirmationScreenViewModelTests.swift in Sources */, - 93875ADD456142D20823ED24 /* ServerSelectionViewModelTests.swift in Sources */, + 67ECD32538F6DAFE38A623F9 /* ServerSelectionScreenViewModelTests.swift in Sources */, CC1C948F67A5510A340FD7F0 /* SessionDirectoriesTests.swift in Sources */, 86675910612A12409262DFBD /* SessionVerificationStateMachineTests.swift in Sources */, 755727E0B756430DFFEC4732 /* SessionVerificationViewModelTests.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index f2cf5c9f4..9425920ed 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -244,8 +244,9 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { private func showLoginScreen() { let parameters = LoginScreenCoordinatorParameters(authenticationService: authenticationService, - analytics: analytics, - userIndicatorController: userIndicatorController) + slidingSyncLearnMoreURL: appSettings.slidingSyncLearnMoreURL, + userIndicatorController: userIndicatorController, + analytics: analytics) let coordinator = LoginScreenCoordinator(parameters: parameters) coordinator.actions diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginMode.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginMode.swift index 775b7eedc..9d7465355 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginMode.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginMode.swift @@ -20,10 +20,8 @@ enum LoginMode: Equatable { var supportsOIDCFlow: Bool { switch self { - case .oidc: - return true - default: - return false + case .oidc: true + default: false } } } diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift index fbbb7409b..66e952475 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift @@ -11,9 +11,9 @@ import SwiftUI struct LoginScreenCoordinatorParameters { /// The service used to authenticate the user. let authenticationService: AuthenticationServiceProtocol - - let analytics: AnalyticsService + let slidingSyncLearnMoreURL: URL let userIndicatorController: UserIndicatorControllerProtocol + let analytics: AnalyticsService } enum LoginScreenCoordinatorAction { @@ -42,8 +42,10 @@ final class LoginScreenCoordinator: CoordinatorProtocol { init(parameters: LoginScreenCoordinatorParameters) { self.parameters = parameters - viewModel = LoginScreenViewModel(homeserver: parameters.authenticationService.homeserver.value, - slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL) + viewModel = LoginScreenViewModel(authenticationService: parameters.authenticationService, + slidingSyncLearnMoreURL: parameters.slidingSyncLearnMoreURL, + userIndicatorController: parameters.userIndicatorController, + analytics: parameters.analytics) } // MARK: - Public @@ -54,119 +56,20 @@ final class LoginScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { - case .parseUsername(let username): - parseUsername(username) - case .forgotPassword: - showForgotPasswordScreen() - case .login(let username, let password): - login(username: username, password: password) + case .configuredForOIDC: + actionsSubject.send(.configuredForOIDC) + case .signedIn(let userSession): + actionsSubject.send(.signedIn(userSession)) } } .store(in: &cancellables) } func stop() { - stopLoading() + viewModel.stopLoading() } func toPresentable() -> AnyView { AnyView(LoginScreen(context: viewModel.context)) } - - // MARK: - Private - - private static let loadingIndicatorIdentifier = "\(LoginScreenCoordinatorAction.self)-Loading" - - private func startLoading(isInteractionBlocking: Bool) { - if isInteractionBlocking { - parameters.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, - type: .modal, - title: L10n.commonLoading, - persistent: true)) - } else { - viewModel.update(isLoading: true) - } - } - - private func stopLoading() { - viewModel.update(isLoading: false) - parameters.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) - } - - /// Processes an error to either update the flow or display it to the user. - private func handleError(_ error: AuthenticationServiceError) { - MXLog.info("Error occurred: \(error)") - - switch error { - case .invalidCredentials: - viewModel.displayError(.alert(L10n.screenLoginErrorInvalidCredentials)) - case .accountDeactivated: - viewModel.displayError(.alert(L10n.screenLoginErrorDeactivatedAccount)) - case .invalidWellKnown(let error): - viewModel.displayError(.invalidWellKnownAlert(error)) - case .slidingSyncNotAvailable: - viewModel.displayError(.slidingSyncAlert) - case .sessionTokenRefreshNotSupported: - viewModel.displayError(.refreshTokenAlert) - default: - viewModel.displayError(.alert(L10n.errorUnknown)) - } - } - - /// Requests the authentication coordinator to log in using the specified credentials. - private func login(username: String, password: String) { - MXLog.info("Starting login with password.") - startLoading(isInteractionBlocking: true) - - Task { - parameters.analytics.signpost.beginLogin() - switch await authenticationService.login(username: username, - password: password, - initialDeviceName: UIDevice.current.initialDeviceName, - deviceID: nil) { - case .success(let userSession): - actionsSubject.send(.signedIn(userSession)) - parameters.analytics.signpost.endLogin() - stopLoading() - case .failure(let error): - stopLoading() - parameters.analytics.signpost.endLogin() - handleError(error) - } - } - } - - /// Parses the specified username and looks up the homeserver when a Matrix ID is entered. - private func parseUsername(_ username: String) { - guard MatrixEntityRegex.isMatrixUserIdentifier(username) else { return } - - let homeserverDomain = String(username.split(separator: ":")[1]) - - startLoading(isInteractionBlocking: false) - - Task { - switch await authenticationService.configure(for: homeserverDomain, flow: .login) { - case .success: - stopLoading() - if authenticationService.homeserver.value.loginMode == .oidc { - actionsSubject.send(.configuredForOIDC) - } else { - updateViewModel() - } - case .failure(let error): - stopLoading() - handleError(error) - } - } - } - - /// Updates the view model with a different homeserver. - private func updateViewModel() { - viewModel.update(homeserver: authenticationService.homeserver.value) - } - - /// Shows the forgot password screen. - private func showForgotPasswordScreen() { - viewModel.displayError(.alert("Not implemented.")) - } } diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenModels.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenModels.swift index e9c80169a..b7979bd7c 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenModels.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenModels.swift @@ -7,23 +7,16 @@ import Foundation -enum LoginScreenViewModelAction: CustomStringConvertible { - /// Parse the username and update the homeserver if included. - case parseUsername(String) - /// The user would like to reset their password. - case forgotPassword - /// Login using the supplied credentials. - case login(username: String, password: String) +enum LoginScreenViewModelAction { + /// The homeserver was updated to one that supports OIDC. + case configuredForOIDC + /// Login was successful. + case signedIn(UserSessionProtocol) - /// A string representation of the action, ignoring any associated values that could leak PII. - var description: String { + var isConfiguredForOIDC: Bool { switch self { - case .parseUsername: - return "parseUsername" - case .forgotPassword: - return "forgotPassword" - case .login: - return "login" + case .configuredForOIDC: true + default: false } } } @@ -34,7 +27,7 @@ struct LoginScreenViewState: BindableState { /// Whether a new homeserver is currently being loaded. var isLoading = false /// View state that can be bound to from SwiftUI. - var bindings: LoginScreenBindings + var bindings = LoginScreenBindings() /// The types of login supported by the homeserver. var loginMode: LoginMode { homeserver.loginMode } @@ -62,8 +55,6 @@ struct LoginScreenBindings { enum LoginScreenViewAction { /// Parse the username to detect if a homeserver is included. case parseUsername - /// The user would like to reset their password. - case forgotPassword /// Continue using the input username and password. case next } @@ -71,8 +62,10 @@ enum LoginScreenViewAction { enum LoginScreenErrorType: Hashable { /// A specific error message shown in an alert. case alert(String) - /// Looking up the homeserver from the username failed. - case invalidHomeserver + /// An alert that informs the user to check their username/password. + case credentialsAlert + /// An alert that informs the user that their account has been deactivated. + case deactivatedAlert /// An alert that informs the user about a bad well-known file. case invalidWellKnownAlert(String) /// An alert that allows the user to learn about sliding sync. diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift index 0c81c9e41..3935c8e30 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift @@ -11,57 +11,129 @@ import SwiftUI typealias LoginScreenViewModelType = StateStoreViewModel class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtocol { + private let authenticationService: AuthenticationServiceProtocol private let slidingSyncLearnMoreURL: URL + private let userIndicatorController: UserIndicatorControllerProtocol + private let analytics: AnalyticsService private var actionsSubject: PassthroughSubject = .init() - var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(homeserver: LoginHomeserver, slidingSyncLearnMoreURL: URL) { + init(authenticationService: AuthenticationServiceProtocol, + slidingSyncLearnMoreURL: URL, + userIndicatorController: UserIndicatorControllerProtocol, + analytics: AnalyticsService) { + self.authenticationService = authenticationService self.slidingSyncLearnMoreURL = slidingSyncLearnMoreURL - let bindings = LoginScreenBindings() - let viewState = LoginScreenViewState(homeserver: homeserver, bindings: bindings) + self.userIndicatorController = userIndicatorController + self.analytics = analytics + + let viewState = LoginScreenViewState(homeserver: authenticationService.homeserver.value) super.init(initialViewState: viewState) + + authenticationService.homeserver + .receive(on: DispatchQueue.main) + .weakAssign(to: \.state.homeserver, on: self) + .store(in: &cancellables) } override func process(viewAction: LoginScreenViewAction) { switch viewAction { case .parseUsername: - actionsSubject.send(.parseUsername(state.bindings.username)) - case .forgotPassword: - actionsSubject.send(.forgotPassword) + parseUsername() case .next: - actionsSubject.send(.login(username: state.bindings.username, password: state.bindings.password)) + login() } } - func update(isLoading: Bool) { - guard state.isLoading != isLoading else { return } - state.isLoading = isLoading + func stopLoading() { + state.isLoading = false + userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) } - func update(homeserver: LoginHomeserver) { - state.homeserver = homeserver + // MARK: - Private + + /// Parses the specified username and looks up the homeserver when a Matrix ID is entered. + private func parseUsername() { + let username = state.bindings.username + + guard MatrixEntityRegex.isMatrixUserIdentifier(username) else { return } + + let homeserverDomain = String(username.split(separator: ":")[1]) + + startLoading(isInteractionBlocking: false) + + Task { + switch await authenticationService.configure(for: homeserverDomain, flow: .login) { + case .success: + if authenticationService.homeserver.value.loginMode == .oidc { + actionsSubject.send(.configuredForOIDC) + } + stopLoading() + case .failure(let error): + stopLoading() + handleError(error) + } + } } - func displayError(_ type: LoginScreenErrorType) { - switch type { - case .alert(let message): - state.bindings.alertInfo = AlertInfo(id: type, + /// Requests the authentication coordinator to log in using the specified credentials. + private func login() { + MXLog.info("Starting login with password.") + startLoading(isInteractionBlocking: true) + + Task { + analytics.signpost.beginLogin() + switch await authenticationService.login(username: state.bindings.username, + password: state.bindings.password, + initialDeviceName: UIDevice.current.initialDeviceName, + deviceID: nil) { + case .success(let userSession): + actionsSubject.send(.signedIn(userSession)) + analytics.signpost.endLogin() + stopLoading() + case .failure(let error): + stopLoading() + analytics.signpost.endLogin() + handleError(error) + } + } + } + + private static let loadingIndicatorIdentifier = "\(LoginScreenCoordinatorAction.self)-Loading" + + private func startLoading(isInteractionBlocking: Bool) { + if isInteractionBlocking { + userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, + type: .modal, + title: L10n.commonLoading, + persistent: true)) + } else { + state.isLoading = true + } + } + + /// Processes an error to either update the flow or display it to the user. + private func handleError(_ error: AuthenticationServiceError) { + MXLog.info("Error occurred: \(error)") + + switch error { + case .invalidCredentials: + state.bindings.alertInfo = AlertInfo(id: .credentialsAlert, title: L10n.commonError, - message: message) - case .invalidHomeserver: - state.bindings.alertInfo = AlertInfo(id: type, + message: L10n.screenLoginErrorInvalidCredentials) + case .accountDeactivated: + state.bindings.alertInfo = AlertInfo(id: .deactivatedAlert, title: L10n.commonError, - message: L10n.screenLoginErrorInvalidUserId) - case .invalidWellKnownAlert(let error): + message: L10n.screenLoginErrorDeactivatedAccount) + case .invalidWellKnown(let error): state.bindings.alertInfo = AlertInfo(id: .slidingSyncAlert, title: L10n.commonServerNotSupported, message: L10n.screenChangeServerErrorInvalidWellKnown(error)) - case .slidingSyncAlert: + case .slidingSyncNotAvailable: let openURL = { UIApplication.shared.open(self.slidingSyncLearnMoreURL) } state.bindings.alertInfo = AlertInfo(id: .slidingSyncAlert, title: L10n.commonServerNotSupported, @@ -71,12 +143,12 @@ class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtoc // Clear out the invalid username to avoid an attempted login to matrix.org state.bindings.username = "" - case .refreshTokenAlert: - state.bindings.alertInfo = AlertInfo(id: type, + case .sessionTokenRefreshNotSupported: + state.bindings.alertInfo = AlertInfo(id: .refreshTokenAlert, title: L10n.commonServerNotSupported, message: L10n.screenLoginErrorRefreshTokens) - case .unknown: - state.bindings.alertInfo = AlertInfo(id: type) + default: + state.bindings.alertInfo = AlertInfo(id: .unknown) } } } diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModelProtocol.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModelProtocol.swift index 303c6151c..cccafd219 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModelProtocol.swift @@ -12,15 +12,6 @@ protocol LoginScreenViewModelProtocol { var actions: AnyPublisher { get } var context: LoginScreenViewModelType.Context { get } - /// Update the view to reflect that a new homeserver is being loaded. - /// - Parameter isLoading: Whether or not the homeserver is being loaded. - func update(isLoading: Bool) - - /// Update the view with new homeserver information. - /// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`. - func update(homeserver: LoginHomeserver) - - /// Display an error to the user. - /// - Parameter type: The type of error to be displayed. - func displayError(_ type: LoginScreenErrorType) + /// Update the view to reflect that loaded has finished. + func stopLoading() } diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift index 69408cd75..1610591e6 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift @@ -29,6 +29,7 @@ struct LoginScreen: View { // This should never be shown. ProgressView() default: + // This should never be shown either. loginUnavailableText } } @@ -37,6 +38,7 @@ struct LoginScreen: View { .padding(.bottom, 16) } .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) .alert(item: $context.alertInfo) } @@ -124,35 +126,45 @@ struct LoginScreen: View { // MARK: - Previews struct LoginScreen_Previews: PreviewProvider, TestablePreview { - static let credentialsViewModel: LoginScreenViewModel = { - let viewModel = LoginScreenViewModel(homeserver: .mockMatrixDotOrg, slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL) - viewModel.context.username = "alice" - viewModel.context.password = "password" - return viewModel - }() + static let viewModel = makeViewModel() + static let credentialsViewModel = makeViewModel(withCredentials: true) + static let unconfiguredViewModel = makeViewModel(homeserverAddress: "somethingtofailconfiguration") static var previews: some View { - screen(for: LoginScreenViewModel(homeserver: .mockMatrixDotOrg, slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL)) - .previewDisplayName("matrix.org") - screen(for: credentialsViewModel) - .previewDisplayName("Credentials Entered") - screen(for: LoginScreenViewModel(homeserver: .mockMatrixDotOrg, slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL)) - .previewDisplayName("Unsupported") - screen(for: LoginScreenViewModel(homeserver: .mockMatrixDotOrg, slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL)) - .previewDisplayName("OIDC Fallback") - } - - static func screen(for viewModel: LoginScreenViewModel) -> some View { NavigationStack { LoginScreen(context: viewModel.context) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button { } label: { - Text("\(Image(systemName: "chevron.backward")) Back") - } - } - } } + .previewDisplayName("matrix.org") + .snapshotPreferences(delay: 0.1) + + NavigationStack { + LoginScreen(context: credentialsViewModel.context) + } + .previewDisplayName("Credentials Entered") + .snapshotPreferences(delay: 0.1) + + NavigationStack { + LoginScreen(context: unconfiguredViewModel.context) + } + .previewDisplayName("Unsupported") + .snapshotPreferences(delay: 0.1) + } + + static func makeViewModel(homeserverAddress: String = "matrix.org", withCredentials: Bool = false) -> LoginScreenViewModel { + let authenticationService = AuthenticationService.mock + + Task { await authenticationService.configure(for: homeserverAddress, flow: .login) } + + let viewModel = LoginScreenViewModel(authenticationService: authenticationService, + slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL, + userIndicatorController: UserIndicatorControllerMock(), + analytics: ServiceLocator.shared.analytics) + + if withCredentials { + viewModel.context.username = "alice" + viewModel.context.password = "password" + } + + return viewModel } } diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift index 127bf29ed..b893a2532 100644 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift @@ -37,10 +37,8 @@ class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType, authenticationService.homeserver .receive(on: DispatchQueue.main) - .sink { [weak self] homeserver in - guard let self else { return } - state.homeserverAddress = homeserver.address - } + .map(\.address) + .weakAssign(to: \.state.homeserverAddress, on: self) .store(in: &cancellables) } diff --git a/ElementX/Sources/Services/Authentication/AuthenticationService.swift b/ElementX/Sources/Services/Authentication/AuthenticationService.swift index 64f5b91f7..092a09702 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationService.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationService.swift @@ -204,9 +204,11 @@ class AuthenticationService: AuthenticationServiceProtocol { // MARK: - Mocks extension AuthenticationService { - static var mock = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), - encryptionKeyProvider: EncryptionKeyProvider(), - clientBuilderFactory: AuthenticationClientBuilderFactoryMock(configuration: .init()), - appSettings: ServiceLocator.shared.settings, - appHooks: AppHooks()) + static var mock: AuthenticationService { + AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), + encryptionKeyProvider: EncryptionKeyProvider(), + clientBuilderFactory: AuthenticationClientBuilderFactoryMock(configuration: .init()), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks()) + } } diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.Credentials-Entered.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.Credentials-Entered.png index 9ba7a98cd..dce2d64cd 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.Credentials-Entered.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.Credentials-Entered.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79822b8f61589c6f261eb71f0ab15c6a6bd2d9f386499c8bc6a2db22628ecbcf -size 95885 +oid sha256:268e4db4b7fbbabd0804c70d5532c8632ee225d633304da1e136c489332ab306 +size 92683 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.OIDC-Fallback.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.OIDC-Fallback.png deleted file mode 100644 index 44e717270..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.OIDC-Fallback.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3d59c0c3eee314426f46098f5c2341520eecfe59479cf1f6f79e967636b4b443 -size 99155 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.Unsupported.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.Unsupported.png index 44e717270..93f591974 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.Unsupported.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.Unsupported.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d59c0c3eee314426f46098f5c2341520eecfe59479cf1f6f79e967636b4b443 -size 99155 +oid sha256:d93585f86e776c0636eb9457cfa6acd35dfef6a8e9591b817aa9b41470ffb1cb +size 95029 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.matrix-org.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.matrix-org.png index 44e717270..737046c6c 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.matrix-org.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-en-GB.matrix-org.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d59c0c3eee314426f46098f5c2341520eecfe59479cf1f6f79e967636b4b443 -size 99155 +oid sha256:9369cd8554cdfc4d1e53d73b33a890db9c7f9e8e39fe4075a58999ec5b2df8ca +size 95979 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.Credentials-Entered.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.Credentials-Entered.png index f3f2e1388..393349d96 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.Credentials-Entered.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.Credentials-Entered.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:253ca4671f65641fabd2ada1a7789821585e1c3e0146e098f96dc4a90280f1d6 -size 101236 +oid sha256:7821d479f4e7230da2df271d889e5c6147edadfbd2b9cadd391a7959605d74a4 +size 96911 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.OIDC-Fallback.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.OIDC-Fallback.png deleted file mode 100644 index 0084793ed..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.OIDC-Fallback.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f40def8fbafb7262bb0fcbb806c75679ceb0c4912264e1dc912783bc5c627c21 -size 105228 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.Unsupported.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.Unsupported.png index 0084793ed..f87f254ae 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.Unsupported.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.Unsupported.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f40def8fbafb7262bb0fcbb806c75679ceb0c4912264e1dc912783bc5c627c21 -size 105228 +oid sha256:374a57ae7fcf402b65b10a2863b0a05128ac111b97b0ee88298d880f1fc8fb7c +size 116598 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.matrix-org.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.matrix-org.png index 0084793ed..d3cc66a10 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.matrix-org.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPad-pseudo.matrix-org.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f40def8fbafb7262bb0fcbb806c75679ceb0c4912264e1dc912783bc5c627c21 -size 105228 +oid sha256:415182fb2d280fdd1c6b723383621acad2275b1d2286de57e718e21c274e3823 +size 100924 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.Credentials-Entered.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.Credentials-Entered.png index 83d393220..01556de3d 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.Credentials-Entered.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.Credentials-Entered.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e97384c64091c5b271c22a3487be59ed573659b0909464c535917cbf30cdf8c -size 51890 +oid sha256:f305e0b0e2423bd83e825f0a5fff222fc751ba9a9a397a48ec8884191fa649af +size 47648 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.OIDC-Fallback.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.OIDC-Fallback.png deleted file mode 100644 index 43857b08f..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.OIDC-Fallback.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5c3efceb5056772c54a8f61e8c30b9a9e9c404b52ab089c57a651c54f52f82ba -size 54748 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.Unsupported.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.Unsupported.png index 43857b08f..8787a8792 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.Unsupported.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.Unsupported.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c3efceb5056772c54a8f61e8c30b9a9e9c404b52ab089c57a651c54f52f82ba -size 54748 +oid sha256:c25500ebf350efd1e8fee87c2031b7caeb6e525e22a65db6d793301749a8474b +size 55931 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.matrix-org.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.matrix-org.png index 43857b08f..b46de1923 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.matrix-org.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-en-GB.matrix-org.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c3efceb5056772c54a8f61e8c30b9a9e9c404b52ab089c57a651c54f52f82ba -size 54748 +oid sha256:b57da444c1ba2db6cc6cc5f465753f56911079916a857d63435b79f1c64d6e00 +size 50637 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.Credentials-Entered.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.Credentials-Entered.png index 40f4a1bdc..8bc5c3f3b 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.Credentials-Entered.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.Credentials-Entered.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82da7f2c937aba87f3b0b6b8112e1bd57335c3292588f365f6824899baff129d -size 59471 +oid sha256:40434e10facf844daa52896c3cabb1ac9dfc05cfb1d25ea358ca1f0387012655 +size 55739 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.OIDC-Fallback.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.OIDC-Fallback.png deleted file mode 100644 index 727fafdfb..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.OIDC-Fallback.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7e0f90b1d7bcc87d9cb94ee21b61133920e02191990d02442911a7135e1fd2a1 -size 63547 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.Unsupported.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.Unsupported.png index 727fafdfb..faba525b6 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.Unsupported.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.Unsupported.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e0f90b1d7bcc87d9cb94ee21b61133920e02191990d02442911a7135e1fd2a1 -size 63547 +oid sha256:5eebc69dcd0285b9e83b7594f157f8aa45ebe217d2a186e981d821dd81847d46 +size 80515 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.matrix-org.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.matrix-org.png index 727fafdfb..974eeb50e 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.matrix-org.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_loginScreen-iPhone-16-pseudo.matrix-org.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e0f90b1d7bcc87d9cb94ee21b61133920e02191990d02442911a7135e1fd2a1 -size 63547 +oid sha256:76dbdda9f2d9b68d64a912d8d1b3e945449d71e6c5f557ae426d97e9b6b3a3ae +size 59817 diff --git a/UnitTests/Sources/LoginScreenViewModelTests.swift b/UnitTests/Sources/LoginScreenViewModelTests.swift new file mode 100644 index 000000000..859b0ab06 --- /dev/null +++ b/UnitTests/Sources/LoginScreenViewModelTests.swift @@ -0,0 +1,173 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import XCTest + +@testable import ElementX + +@MainActor +class LoginScreenViewModelTests: XCTestCase { + var viewModel: LoginScreenViewModelProtocol! + var context: LoginScreenViewModelType.Context { viewModel.context } + + var clientBuilderFactory: AuthenticationClientBuilderFactoryMock! + var service: AuthenticationServiceProtocol! + + private func setupViewModel(homeserverAddress: String = "matrix.org") async { + clientBuilderFactory = AuthenticationClientBuilderFactoryMock(configuration: .init()) + service = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), + encryptionKeyProvider: EncryptionKeyProvider(), + clientBuilderFactory: clientBuilderFactory, + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks()) + + guard case .success = await service.configure(for: homeserverAddress, flow: .login) else { + XCTFail("A valid server should be configured for the test.") + return + } + + viewModel = LoginScreenViewModel(authenticationService: service, + slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL, + userIndicatorController: UserIndicatorControllerMock(), + analytics: ServiceLocator.shared.analytics) + } + + func testMatrixDotOrg() async { + // Given the initial view model configured for matrix.org. + await setupViewModel() + + // Then the view state should contain a homeserver that matches matrix.org and show the login form. + XCTAssertEqual(context.viewState.homeserver, .mockMatrixDotOrg, "The homeserver data should match the default homeserver.") + XCTAssertEqual(context.viewState.loginMode, .password, "The login form should be shown.") + } + + func testBasicServer() async { + // Given the view model configured for a basic server example.com that only supports password authentication. + await setupViewModel(homeserverAddress: "example.com") + + // Then the view state should be updated with the homeserver and show the login form. + XCTAssertEqual(context.viewState.homeserver, .mockBasicServer, "The homeserver data should should match the new homeserver.") + XCTAssertEqual(context.viewState.loginMode, .password, "The login form should be shown.") + } + + func testUsernameWithEmptyPassword() async { + // Given a form with an empty username and password. + await setupViewModel() + XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") + XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") + + // When entering a username without a password. + context.username = "bob" + context.password = "" + + // Then the credentials should be considered invalid. + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") + } + + func testEmptyUsernameWithPassword() async { + // Given a form with an empty username and password. + await setupViewModel() + XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") + XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") + + // When entering a password without a username. + context.username = "" + context.password = "12345678" + + // Then the credentials should be considered invalid. + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") + } + + func testValidCredentials() async { + // Given a form with an empty username and password. + await setupViewModel() + XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") + XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") + + // When entering a username and an 8-character password. + context.username = "bob" + context.password = "12345678" + + // Then the credentials should be considered valid. + XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid when the username and password are valid.") + XCTAssertTrue(context.viewState.canSubmit, "The form should be ready to submit.") + } + + func testLoadingServerWithoutPassword() async throws { + // Given a form with valid credentials. + await setupViewModel() + context.username = "@bob:example.com" + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be not be valid without a password.") + XCTAssertFalse(context.viewState.isLoading, "The view shouldn't start in a loading state.") + XCTAssertFalse(context.viewState.canSubmit, "The form should not be submittable.") + + // When updating the view model whilst loading a homeserver. + let deferred = deferFulfillment(context.$viewState, keyPath: \.isLoading, transitionValues: [true, false]) + context.send(viewAction: .parseUsername) + + // Then the view state should represent the loading but never allow submitting to occur. + try await deferred.fulfill() + XCTAssertFalse(context.viewState.isLoading, "The view should be back in a loaded state.") + XCTAssertFalse(context.viewState.canSubmit, "The form should still not be submittable.") + } + + func testLoadingServerWithPasswordEntered() async throws { + // Given a form with valid credentials. + await setupViewModel() + context.username = "@bob:example.com" + context.password = "12345678" + XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid.") + XCTAssertFalse(context.viewState.isLoading, "The view shouldn't start in a loading state.") + XCTAssertTrue(context.viewState.canSubmit, "The form should be ready to submit.") + + // When updating the view model whilst loading a homeserver. + let deferred = deferFulfillment(context.$viewState, keyPath: \.canSubmit, transitionValues: [false, true]) + context.send(viewAction: .parseUsername) + + // Then the view should be blocked from submitting while loading and then become unblocked again. + try await deferred.fulfill() + XCTAssertFalse(context.viewState.isLoading, "The view should be back in a loaded state.") + XCTAssertTrue(context.viewState.canSubmit, "The form should be ready to submit.") + } + + func testOIDCServer() async throws { + // Given the screen configured for matrix.org + await setupViewModel() + + // When entering a username for a user on a homeserver with OIDC. + let deferred = deferFulfillment(viewModel.actions) { $0.isConfiguredForOIDC } + context.username = "@bob:company.com" + context.send(viewAction: .parseUsername) + try await deferred.fulfill() + + // Then the view state should be updated with the homeserver and show the OIDC button. + XCTAssertTrue(context.viewState.loginMode.supportsOIDCFlow, "The OIDC button should be shown.") + } + + func testUnsupportedServer() async throws { + // Given the screen configured for matrix.org + await setupViewModel() + XCTAssertNil(context.alertInfo, "There shouldn't be an alert when the screen loads.") + + // When entering a username for an unsupported homeserver. + let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } + context.username = "@bob:server.net" + context.send(viewAction: .parseUsername) + try await deferred.fulfill() + + // Then the view state should be updated to show an alert. + XCTAssertEqual(context.alertInfo?.id, .unknown, "An alert should be shown to the user.") + } +} diff --git a/UnitTests/Sources/LoginViewModelTests.swift b/UnitTests/Sources/LoginViewModelTests.swift deleted file mode 100644 index ba02c88a9..000000000 --- a/UnitTests/Sources/LoginViewModelTests.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// Copyright 2022-2024 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. -// - -import XCTest - -@testable import ElementX - -@MainActor -class LoginViewModelTests: XCTestCase { - let defaultHomeserver = LoginHomeserver.mockMatrixDotOrg - var viewModel: LoginScreenViewModelProtocol! - var context: LoginScreenViewModelType.Context! - - @MainActor override func setUp() async throws { - viewModel = LoginScreenViewModel(homeserver: defaultHomeserver, slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL) - context = viewModel.context - } - - func testMatrixDotOrg() { - // Given the initial view model configured for matrix.org. - let homeserver = defaultHomeserver - - // Then the view state should contain a homeserver that matches matrix.org and show the login form. - XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should match the original.") - XCTAssertEqual(context.viewState.loginMode, .password, "The login form should be shown.") - } - - func testBasicServer() { - // Given a basic server example.com that only supports password registration. - let homeserver = LoginHomeserver.mockBasicServer - - // When updating the view model with the server. - viewModel.update(homeserver: homeserver) - - // Then the view state should be updated with the homeserver and show the login form. - XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should should match the new homeserver.") - XCTAssertEqual(context.viewState.loginMode, .password, "The login form should be shown.") - } - - func testUsernameWithEmptyPassword() { - // Given a form with an empty username and password. - XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") - XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") - XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") - XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") - - // When entering a username without a password. - context.username = "bob" - context.password = "" - - // Then the credentials should be considered invalid. - XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") - XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") - } - - func testEmptyUsernameWithPassword() { - // Given a form with an empty username and password. - XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") - XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") - XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") - XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") - - // When entering a password without a username. - context.username = "" - context.password = "12345678" - - // Then the credentials should be considered invalid. - XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") - XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") - } - - func testValidCredentials() { - // Given a form with an empty username and password. - XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") - XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") - XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") - XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") - - // When entering a username and an 8-character password. - context.username = "bob" - context.password = "12345678" - - // Then the credentials should be considered valid. - XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid when the username and password are valid.") - XCTAssertTrue(context.viewState.canSubmit, "The form should be ready to submit.") - } - - func testLoadingServer() { - // Given a form with valid credentials. - context.username = "bob" - context.password = "12345678" - XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid.") - XCTAssertFalse(context.viewState.isLoading, "The view shouldn't start in a loading state.") - XCTAssertTrue(context.viewState.canSubmit, "The form should be ready to submit.") - - // When updating the view model whilst loading a homeserver. - viewModel.update(isLoading: true) - - // Then the view state should reflect that the homeserver is loading. - XCTAssertTrue(context.viewState.isLoading, "The view should now be in a loading state.") - XCTAssertFalse(context.viewState.canSubmit, "The form should be blocked for submission.") - - // When updating the view model after loading a homeserver. - viewModel.update(isLoading: false) - - // Then the view state should reflect that the homeserver is now loaded. - XCTAssertFalse(context.viewState.isLoading, "The view should be back in a loaded state.") - XCTAssertTrue(context.viewState.canSubmit, "The form should be ready to submit.") - } - - func testOIDCServer() { - // Given a basic server example.com that supports OIDC registration. - let homeserver = LoginHomeserver.mockOIDC - - // When updating the view model with the server. - viewModel.update(homeserver: homeserver) - - // Then the view state should be updated with the homeserver and show the OIDC button. - XCTAssertTrue(context.viewState.loginMode.supportsOIDCFlow, "The OIDC button should be shown.") - } - - func testLogsForPassword() { - // Given the coordinator and view model results that contain passwords. - let password = "supersecretpassword" - let viewModelAction: LoginScreenViewModelAction = .login(username: "Alice", password: password) - - // When creating a string representation of those results (e.g. for logging). - let viewModelActionString = "\(viewModelAction)" - - // Then the password should not be included in that string. - XCTAssertFalse("\(viewModelActionString)".contains(password), "The password must not be included in any strings.") - } -} diff --git a/UnitTests/Sources/ServerSelectionViewModelTests.swift b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift similarity index 99% rename from UnitTests/Sources/ServerSelectionViewModelTests.swift rename to UnitTests/Sources/ServerSelectionScreenViewModelTests.swift index fc6411102..a4fd80869 100644 --- a/UnitTests/Sources/ServerSelectionViewModelTests.swift +++ b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift @@ -10,7 +10,7 @@ import XCTest @testable import ElementX @MainActor -class ServerSelectionViewModelTests: XCTestCase { +class ServerSelectionScreenViewModelTests: XCTestCase { var clientBuilderFactory: AuthenticationClientBuilderFactoryMock! var service: AuthenticationServiceProtocol!