Move the core logic in LoginScreenCoordinator into the ViewModel. (#3348)

This commit is contained in:
Doug
2024-10-01 13:09:45 +01:00
committed by GitHub
parent 5f4c2890f6
commit 268d9f7479
29 changed files with 382 additions and 388 deletions
@@ -20,10 +20,8 @@ enum LoginMode: Equatable {
var supportsOIDCFlow: Bool {
switch self {
case .oidc:
return true
default:
return false
case .oidc: true
default: false
}
}
}
@@ -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."))
}
}
@@ -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.
@@ -11,57 +11,129 @@ import SwiftUI
typealias LoginScreenViewModelType = StateStoreViewModel<LoginScreenViewState, LoginScreenViewAction>
class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtocol {
private let authenticationService: AuthenticationServiceProtocol
private let slidingSyncLearnMoreURL: URL
private let userIndicatorController: UserIndicatorControllerProtocol
private let analytics: AnalyticsService
private var actionsSubject: PassthroughSubject<LoginScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<LoginScreenViewModelAction, Never> {
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)
}
}
}
@@ -12,15 +12,6 @@ protocol LoginScreenViewModelProtocol {
var actions: AnyPublisher<LoginScreenViewModelAction, Never> { 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()
}
@@ -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
}
}
@@ -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)
}