mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
#40: Add basic AuthenticationService and missing UI tests.
* Add MockAuthenticationService and ServerSelectionUITests. * Add tests covering the Authentication flow.
This commit is contained in:
@@ -6,28 +6,18 @@
|
||||
// Copyright © 2022 Element. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import MatrixRustSDK
|
||||
|
||||
enum AuthenticationCoordinatorError: Error {
|
||||
case failedLoggingIn
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol AuthenticationCoordinatorDelegate: AnyObject {
|
||||
|
||||
func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator)
|
||||
|
||||
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator,
|
||||
didLoginWithSession userSession: UserSessionProtocol)
|
||||
|
||||
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator,
|
||||
didFailWithError error: AuthenticationCoordinatorError)
|
||||
}
|
||||
|
||||
class AuthenticationCoordinator: Coordinator {
|
||||
class AuthenticationCoordinator: Coordinator, Presentable {
|
||||
|
||||
private let userSessionStore: UserSessionStoreProtocol
|
||||
private let authenticationService: AuthenticationServiceProtocol
|
||||
private let navigationRouter: NavigationRouter
|
||||
|
||||
private(set) var clientProxy: ClientProxyProtocol?
|
||||
@@ -35,9 +25,9 @@ class AuthenticationCoordinator: Coordinator {
|
||||
|
||||
weak var delegate: AuthenticationCoordinatorDelegate?
|
||||
|
||||
init(userSessionStore: UserSessionStoreProtocol,
|
||||
init(authenticationService: AuthenticationServiceProtocol,
|
||||
navigationRouter: NavigationRouter) {
|
||||
self.userSessionStore = userSessionStore
|
||||
self.authenticationService = authenticationService
|
||||
self.navigationRouter = navigationRouter
|
||||
}
|
||||
|
||||
@@ -45,6 +35,10 @@ class AuthenticationCoordinator: Coordinator {
|
||||
showSplashScreen()
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
navigationRouter.toPresentable()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func showSplashScreen() {
|
||||
@@ -67,28 +61,18 @@ class AuthenticationCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
private func showLoginScreen() {
|
||||
let homeserver = LoginHomeserver(address: BuildSettings.defaultHomeserverURLString)
|
||||
let parameters = LoginCoordinatorParameters(navigationRouter: navigationRouter, homeserver: homeserver)
|
||||
let parameters = LoginCoordinatorParameters(authenticationService: authenticationService,
|
||||
navigationRouter: navigationRouter)
|
||||
let coordinator = LoginCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self, weak coordinator] action in
|
||||
guard let self = self, let coordinator = coordinator else {
|
||||
return
|
||||
}
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
|
||||
switch action {
|
||||
case .login(let username, let password):
|
||||
Task {
|
||||
switch await self.login(username: username, password: password) {
|
||||
case .success(let userSession):
|
||||
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
self.navigationRouter.dismissModule()
|
||||
case .failure(let error):
|
||||
self.delegate?.authenticationCoordinator(self, didFailWithError: error)
|
||||
MXLog.error("Failed logging in user with error: \(error)")
|
||||
}
|
||||
}
|
||||
case .signedIn(let userSession):
|
||||
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
self.navigationRouter.dismissModule()
|
||||
case .continueWithOIDC:
|
||||
break
|
||||
}
|
||||
@@ -101,38 +85,4 @@ class AuthenticationCoordinator: Coordinator {
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
private func login(username: String, password: String) async -> Result<UserSession, AuthenticationCoordinatorError> {
|
||||
Benchmark.startTrackingForIdentifier("Login", message: "Started new login")
|
||||
|
||||
delegate?.authenticationCoordinatorDidStartLoading(self)
|
||||
|
||||
let basePath = userSessionStore.baseDirectoryPath(for: username)
|
||||
let builder = ClientBuilder()
|
||||
.basePath(path: basePath)
|
||||
.username(username: username)
|
||||
|
||||
let loginTask: Task<Client, Error> = Task.detached {
|
||||
let client = try builder.build()
|
||||
try client.login(username: username, password: password)
|
||||
return client
|
||||
}
|
||||
|
||||
switch await loginTask.result {
|
||||
case .success(let client):
|
||||
return await userSession(for: client)
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed logging in with error: \(error)")
|
||||
return .failure(.failedLoggingIn)
|
||||
}
|
||||
}
|
||||
|
||||
private func userSession(for client: Client) async -> Result<UserSession, AuthenticationCoordinatorError> {
|
||||
switch await userSessionStore.userSession(for: client) {
|
||||
case .success(let clientProxy):
|
||||
return .success(clientProxy)
|
||||
case .failure:
|
||||
return .failure(.failedLoggingIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,26 +18,17 @@ import SwiftUI
|
||||
import MatrixRustSDK
|
||||
|
||||
struct LoginCoordinatorParameters {
|
||||
/// The service used to authenticate the user.
|
||||
let authenticationService: AuthenticationServiceProtocol
|
||||
/// The navigation router used to present the server selection screen.
|
||||
let navigationRouter: NavigationRouterType
|
||||
/// The homeserver to be shown initially.
|
||||
let homeserver: LoginHomeserver
|
||||
}
|
||||
|
||||
enum LoginCoordinatorAction: CustomStringConvertible {
|
||||
/// Login with the associated username and password.
|
||||
case login(username: String, password: String)
|
||||
enum LoginCoordinatorAction {
|
||||
/// Login was successful.
|
||||
case signedIn(UserSessionProtocol)
|
||||
/// Continue using OIDC.
|
||||
case continueWithOIDC
|
||||
|
||||
/// A string representation of the action, ignoring any associated values that could leak PII.
|
||||
var description: String {
|
||||
switch self {
|
||||
case .login:
|
||||
return "login"
|
||||
case .continueWithOIDC:
|
||||
return "continueWithOIDC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class LoginCoordinator: Coordinator, Presentable {
|
||||
@@ -56,6 +47,7 @@ final class LoginCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
private var authenticationService: AuthenticationServiceProtocol { parameters.authenticationService }
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var activityIndicator: UserIndicator?
|
||||
@@ -71,7 +63,7 @@ final class LoginCoordinator: Coordinator, Presentable {
|
||||
init(parameters: LoginCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = LoginViewModel(homeserver: parameters.homeserver)
|
||||
let viewModel = LoginViewModel(homeserver: parameters.authenticationService.homeserver)
|
||||
loginViewModel = viewModel
|
||||
|
||||
let view = LoginScreen(context: viewModel.context)
|
||||
@@ -135,56 +127,68 @@ final class LoginCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
|
||||
/// Processes an error to either update the flow or display it to the user.
|
||||
private func handleError(_ error: Error) {
|
||||
loginViewModel.displayError(.alert(error.localizedDescription))
|
||||
private func handleError(_ error: AuthenticationServiceError) {
|
||||
switch error {
|
||||
case .invalidCredentials:
|
||||
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
|
||||
case .accountDeactivated:
|
||||
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
|
||||
default:
|
||||
loginViewModel.displayError(.alert(ElementL10n.unknownError))
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests the authentication coordinator to log in using the specified credentials.
|
||||
private func login(username: String, password: String) {
|
||||
var username = loginViewModel.context.username
|
||||
startLoading(isInteractionBlocking: true)
|
||||
|
||||
if !isMXID(username: username) {
|
||||
let homeserver = loginViewModel.context.viewState.homeserver
|
||||
username = "@\(username):\(homeserver.address)"
|
||||
Task {
|
||||
switch await authenticationService.login(username: username, password: password) {
|
||||
case .success(let userSession):
|
||||
callback?(.signedIn(userSession))
|
||||
stopLoading()
|
||||
case .failure(let error):
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
callback?(.login(username: username, password: password))
|
||||
}
|
||||
|
||||
/// Parses the specified username and looks up the homeserver when a Matrix ID is entered.
|
||||
private func parseUsername(_ username: String) {
|
||||
guard isMXID(username: username) else { return }
|
||||
guard username.isMatrixUserID else { return }
|
||||
|
||||
let domain = String(username.split(separator: ":")[1])
|
||||
let homeserverDomain = String(username.split(separator: ":")[1])
|
||||
|
||||
let homeserver = LoginHomeserver(address: domain)
|
||||
updateViewModel(homeserver: homeserver)
|
||||
indicateSuccess()
|
||||
}
|
||||
|
||||
/// Checks whether the specified username is a Matrix ID or not.
|
||||
private func isMXID(username: String) -> Bool {
|
||||
let range = NSRange(location: 0, length: username.count)
|
||||
startLoading(isInteractionBlocking: false)
|
||||
|
||||
let detector = try? NSRegularExpression(pattern: MatrixEntityRegex.userId.rawValue, options: .caseInsensitive)
|
||||
return detector?.numberOfMatches(in: username, range: range) ?? 0 > 0
|
||||
Task {
|
||||
switch await authenticationService.startLogin(for: homeserverDomain) {
|
||||
case .success:
|
||||
updateViewModel()
|
||||
stopLoading()
|
||||
case .failure(let error):
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the view model with a different homeserver.
|
||||
private func updateViewModel(homeserver: LoginHomeserver) {
|
||||
loginViewModel.update(homeserver: homeserver)
|
||||
private func updateViewModel() {
|
||||
loginViewModel.update(homeserver: authenticationService.homeserver)
|
||||
indicateSuccess()
|
||||
}
|
||||
|
||||
/// Presents the server selection screen as a modal.
|
||||
private func presentServerSelectionScreen() {
|
||||
MXLog.debug("[LoginCoordinator] presentServerSelectionScreen")
|
||||
let parameters = ServerSelectionCoordinatorParameters(homeserver: loginViewModel.context.viewState.homeserver,
|
||||
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
|
||||
hasModalPresentation: true)
|
||||
let coordinator = ServerSelectionCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] result in
|
||||
coordinator.callback = { [weak self, weak coordinator] action in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.serverSelectionCoordinator(coordinator, didCompleteWith: result)
|
||||
self.serverSelectionCoordinator(coordinator, didCompleteWith: action)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
@@ -198,10 +202,10 @@ final class LoginCoordinator: Coordinator, Presentable {
|
||||
|
||||
/// Handles the result from the server selection modal, dismissing it after updating the view.
|
||||
private func serverSelectionCoordinator(_ coordinator: ServerSelectionCoordinator,
|
||||
didCompleteWith result: ServerSelectionCoordinatorResult) {
|
||||
didCompleteWith action: ServerSelectionCoordinatorAction) {
|
||||
navigationRouter.dismissModule(animated: true) { [weak self] in
|
||||
if case let .selected(homeserver) = result {
|
||||
self?.updateViewModel(homeserver: homeserver)
|
||||
if action == .updated {
|
||||
self?.updateViewModel()
|
||||
}
|
||||
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
|
||||
@@ -58,6 +58,7 @@ struct LoginServerInfoSection: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.elementGhost())
|
||||
.accessibilityIdentifier("editServerButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-13
@@ -17,14 +17,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ServerSelectionCoordinatorParameters {
|
||||
/// The homeserver to be shown initially.
|
||||
let homeserver: LoginHomeserver
|
||||
/// The service used to authenticate the user.
|
||||
let authenticationService: AuthenticationServiceProtocol
|
||||
/// Whether the screen is presented modally or within a navigation stack.
|
||||
let hasModalPresentation: Bool
|
||||
}
|
||||
|
||||
enum ServerSelectionCoordinatorResult {
|
||||
case selected(LoginHomeserver)
|
||||
enum ServerSelectionCoordinatorAction {
|
||||
case updated
|
||||
case dismiss
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
|
||||
private let serverSelectionHostingController: UIViewController
|
||||
private var serverSelectionViewModel: ServerSelectionViewModelProtocol
|
||||
|
||||
private var authenticationService: AuthenticationServiceProtocol { parameters.authenticationService }
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
@@ -45,14 +46,14 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: (@MainActor (ServerSelectionCoordinatorResult) -> Void)?
|
||||
var callback: (@MainActor (ServerSelectionCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: ServerSelectionCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = ServerSelectionViewModel(homeserverAddress: parameters.homeserver.address,
|
||||
let viewModel = ServerSelectionViewModel(homeserverAddress: parameters.authenticationService.homeserver.address,
|
||||
hasModalPresentation: parameters.hasModalPresentation)
|
||||
let view = ServerSelectionScreen(context: viewModel.context)
|
||||
serverSelectionViewModel = viewModel
|
||||
@@ -66,11 +67,11 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
|
||||
func start() {
|
||||
MXLog.debug("[ServerSelectionCoordinator] did start.")
|
||||
|
||||
serverSelectionViewModel.callback = { [weak self] result in
|
||||
serverSelectionViewModel.callback = { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[ServerSelectionCoordinator] ServerSelectionViewModel did complete with result: \(result).")
|
||||
MXLog.debug("[ServerSelectionCoordinator] ServerSelectionViewModel did callback with action: \(action).")
|
||||
|
||||
switch result {
|
||||
switch action {
|
||||
case .confirm(let homeserverAddress):
|
||||
self.useHomeserver(homeserverAddress)
|
||||
case .dismiss:
|
||||
@@ -102,9 +103,25 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
|
||||
private func useHomeserver(_ homeserverAddress: String) {
|
||||
startLoading()
|
||||
|
||||
let homeserverAddress = LoginHomeserver.sanitized(homeserverAddress)
|
||||
|
||||
stopLoading()
|
||||
callback?(.selected(LoginHomeserver(address: homeserverAddress)))
|
||||
Task {
|
||||
switch await authenticationService.startLogin(for: homeserverAddress) {
|
||||
case .success:
|
||||
callback?(.updated)
|
||||
stopLoading()
|
||||
case .failure(let error):
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes an error to either update the flow or display it to the user.
|
||||
private func handleError(_ error: AuthenticationServiceError) {
|
||||
switch error {
|
||||
case .invalidServer:
|
||||
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.loginErrorHomeserverNotFound))
|
||||
default:
|
||||
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.unknownError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import Foundation
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum ServerSelectionViewModelResult {
|
||||
enum ServerSelectionViewModelAction {
|
||||
/// The user would like to use the homeserver at the given address.
|
||||
case confirm(homeserverAddress: String)
|
||||
/// Dismiss the view without using the entered address.
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ class ServerSelectionViewModel: ServerSelectionViewModelType, ServerSelectionVie
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: (@MainActor (ServerSelectionViewModelResult) -> Void)?
|
||||
var callback: (@MainActor (ServerSelectionViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import Foundation
|
||||
@MainActor
|
||||
protocol ServerSelectionViewModelProtocol {
|
||||
|
||||
var callback: (@MainActor (ServerSelectionViewModelResult) -> Void)? { get set }
|
||||
var callback: (@MainActor (ServerSelectionViewModelAction) -> Void)? { get set }
|
||||
var context: ServerSelectionViewModelType.Context { get }
|
||||
|
||||
/// Displays an error to the user.
|
||||
|
||||
Reference in New Issue
Block a user