Configure the AuthenticationService later now that we have 2 flows on the start screen. (#3316)

* Don't query the homeserver until confirming it (or selecting a different one).

* Setup the infrastructure to test AuthenticationService.

Implement basic tests for configuration & password login.

* Use the real AuthenticationService with a mock Client in all of the tests.

* Add tests for the ServerConfirmationScreenViewModel.

* Remove redundant view state and test for it.
This commit is contained in:
Doug
2024-09-25 14:40:18 +01:00
committed by GitHub
parent af8c16150b
commit a8dbda90d9
44 changed files with 1209 additions and 298 deletions
@@ -25,6 +25,11 @@ struct LoginHomeserver: Equatable {
self.registrationHelperURL = registrationHelperURL
}
/// Whether or not the app is able to register on this homeserver.
var supportsRegistration: Bool {
loginMode == .oidc || (address == "matrix.org" && registrationHelperURL != nil)
}
/// Sanitizes a user entered homeserver address with the following rules
/// - Trim any whitespace.
/// - Lowercase the address.
@@ -145,7 +145,7 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
startLoading(isInteractionBlocking: false)
Task {
switch await authenticationService.configure(for: homeserverDomain) {
switch await authenticationService.configure(for: homeserverDomain, flow: .login) {
case .success:
stopLoading()
if authenticationService.homeserver.value.loginMode == .oidc {
@@ -11,6 +11,8 @@ import SwiftUI
struct ServerConfirmationScreenCoordinatorParameters {
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let slidingSyncLearnMoreURL: URL
let userIndicatorController: UserIndicatorControllerProtocol
}
enum ServerConfirmationScreenCoordinatorAction {
@@ -29,7 +31,9 @@ final class ServerConfirmationScreenCoordinator: CoordinatorProtocol {
init(parameters: ServerConfirmationScreenCoordinatorParameters) {
viewModel = ServerConfirmationScreenViewModel(authenticationService: parameters.authenticationService,
authenticationFlow: parameters.authenticationFlow)
authenticationFlow: parameters.authenticationFlow,
slidingSyncLearnMoreURL: parameters.slidingSyncLearnMoreURL,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
@@ -19,11 +19,11 @@ struct ServerConfirmationScreenViewState: BindableState {
var homeserverAddress: String
/// The flow being attempted on the selected homeserver.
let authenticationFlow: AuthenticationFlow
/// Whether or not the homeserver supports registration.
var homeserverSupportsRegistration = false
/// The presentation anchor used for OIDC authentication.
var window: UIWindow?
var bindings = ServerConfirmationScreenBindings()
/// The screen's title.
var title: String {
switch authenticationFlow {
@@ -46,23 +46,16 @@ struct ServerConfirmationScreenViewState: BindableState {
""
}
case .register:
if canContinue {
L10n.screenServerConfirmationMessageRegister
} else {
L10n.errorAccountCreationNotPossible
}
}
}
/// Whether or not it is valid to continue the flow.
var canContinue: Bool {
switch authenticationFlow {
case .login: true
case .register: homeserverSupportsRegistration
L10n.screenServerConfirmationMessageRegister
}
}
}
struct ServerConfirmationScreenBindings {
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<ServerConfirmationScreenAlert>?
}
enum ServerConfirmationScreenViewAction {
/// Updates the window used as the OIDC presentation anchor.
case updateWindow(UIWindow)
@@ -71,3 +64,16 @@ enum ServerConfirmationScreenViewAction {
/// The user would like to change to a different homeserver.
case changeServer
}
enum ServerConfirmationScreenAlert: Hashable {
/// An alert that informs the user that a server could not be found.
case homeserverNotFound
/// An alert that informs the user about a bad well-known file.
case invalidWellKnown(String)
/// An alert that allows the user to learn about sliding sync.
case slidingSync
/// An alert that informs the user that registration isn't supported.
case registration
/// An unknown error has occurred.
case unknownError
}
@@ -11,46 +11,118 @@ import SwiftUI
typealias ServerConfirmationScreenViewModelType = StateStoreViewModel<ServerConfirmationScreenViewState, ServerConfirmationScreenViewAction>
class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType, ServerConfirmationScreenViewModelProtocol {
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let slidingSyncLearnMoreURL: URL
let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<ServerConfirmationScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<ServerConfirmationScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(authenticationService: AuthenticationServiceProtocol, authenticationFlow: AuthenticationFlow) {
let homeserver = authenticationService.homeserver.value
init(authenticationService: AuthenticationServiceProtocol,
authenticationFlow: AuthenticationFlow,
slidingSyncLearnMoreURL: URL,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.authenticationFlow = authenticationFlow
self.slidingSyncLearnMoreURL = slidingSyncLearnMoreURL
self.userIndicatorController = userIndicatorController
let homeserver = authenticationService.homeserver.value
super.init(initialViewState: ServerConfirmationScreenViewState(homeserverAddress: homeserver.address,
authenticationFlow: authenticationFlow,
homeserverSupportsRegistration: homeserver.supportsRegistration))
authenticationFlow: authenticationFlow))
authenticationService.homeserver
.receive(on: DispatchQueue.main)
.sink { [weak self] homeserver in
guard let self else { return }
state.homeserverAddress = homeserver.address
state.homeserverSupportsRegistration = homeserver.supportsRegistration
}
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: ServerConfirmationScreenViewAction) {
switch viewAction {
case .updateWindow(let window):
guard state.window != window else { return }
Task { state.window = window }
case .confirm:
actionsSubject.send(.confirm)
Task { await configureAndContinue() }
case .changeServer:
actionsSubject.send(.changeServer)
}
}
}
extension LoginHomeserver {
var supportsRegistration: Bool {
loginMode == .oidc || (address == "matrix.org" && registrationHelperURL != nil)
// MARK: - Private
private func configureAndContinue() async {
let homeserver = authenticationService.homeserver.value
// If the login mode is unknown, the service hasn't be configured and we need to do it now.
// Otherwise we can continue the flow as server selection has been performed and succeeded.
guard homeserver.loginMode == .unknown || authenticationService.flow != authenticationFlow else {
actionsSubject.send(.confirm)
return
}
startLoading()
defer { stopLoading() }
switch await authenticationService.configure(for: homeserver.address, flow: authenticationFlow) {
case .success:
actionsSubject.send(.confirm)
case .failure(let error):
switch error {
case .invalidServer, .invalidHomeserverAddress:
displayError(.homeserverNotFound)
case .invalidWellKnown(let error):
displayError(.invalidWellKnown(error))
case .slidingSyncNotAvailable:
displayError(.slidingSync)
case .registrationNotSupported:
displayError(.registration)
default:
displayError(.unknownError)
}
}
}
private func startLoading(label: String = L10n.commonLoading) {
userIndicatorController.submitIndicator(UserIndicator(type: .modal,
title: label,
persistent: true))
}
private func stopLoading() {
userIndicatorController.retractAllIndicators()
}
private func displayError(_ type: ServerConfirmationScreenAlert) {
switch type {
case .homeserverNotFound:
state.bindings.alertInfo = AlertInfo(id: .homeserverNotFound,
title: L10n.errorUnknown,
message: L10n.screenChangeServerErrorInvalidHomeserver)
case .invalidWellKnown(let error):
state.bindings.alertInfo = AlertInfo(id: .invalidWellKnown(error),
title: L10n.commonServerNotSupported,
message: L10n.screenChangeServerErrorInvalidWellKnown(error))
case .slidingSync:
let openURL = { UIApplication.shared.open(self.slidingSyncLearnMoreURL) }
state.bindings.alertInfo = AlertInfo(id: .slidingSync,
title: L10n.commonServerNotSupported,
message: L10n.screenChangeServerErrorNoSlidingSyncMessage,
primaryButton: .init(title: L10n.actionLearnMore, role: .cancel, action: openURL),
secondaryButton: .init(title: L10n.actionCancel, action: nil))
case .registration:
state.bindings.alertInfo = AlertInfo(id: .registration,
title: L10n.errorUnknown,
message: L10n.errorAccountCreationNotPossible)
case .unknownError:
state.bindings.alertInfo = AlertInfo(id: .unknownError)
}
}
}
@@ -19,6 +19,7 @@ struct ServerConfirmationScreen: View {
}
.background()
.backgroundStyle(.compound.bgCanvasDefault)
.alert(item: $context.alertInfo)
.introspect(.window, on: .supportedVersions) { window in
context.send(viewAction: .updateWindow(window))
}
@@ -53,7 +54,6 @@ struct ServerConfirmationScreen: View {
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.continue)
.disabled(!context.viewState.canContinue)
Button { context.send(viewAction: .changeServer) } label: {
Text(L10n.screenServerConfirmationChangeServer)
@@ -68,10 +68,8 @@ struct ServerConfirmationScreen: View {
// MARK: - Previews
struct ServerConfirmationScreen_Previews: PreviewProvider, TestablePreview {
static let loginViewModel = ServerConfirmationScreenViewModel(authenticationService: MockAuthenticationService(),
authenticationFlow: .login)
static let registerViewModel = ServerConfirmationScreenViewModel(authenticationService: MockAuthenticationService(),
authenticationFlow: .register)
static let loginViewModel = makeViewModel(flow: .login)
static let registerViewModel = makeViewModel(flow: .register)
static var previews: some View {
NavigationStack {
@@ -86,4 +84,11 @@ struct ServerConfirmationScreen_Previews: PreviewProvider, TestablePreview {
}
.previewDisplayName("Register")
}
static func makeViewModel(flow: AuthenticationFlow) -> ServerConfirmationScreenViewModel {
ServerConfirmationScreenViewModel(authenticationService: AuthenticationService.mock,
authenticationFlow: flow,
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -11,30 +11,22 @@ enum MockServerSelectionScreenState: CaseIterable {
case matrix
case emptyAddress
case invalidAddress
case nonModal
/// Generate the view struct for the screen state.
@MainActor var viewModel: ServerSelectionScreenViewModel {
switch self {
case .matrix:
return ServerSelectionScreenViewModel(homeserverAddress: "https://matrix.org",
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: true)
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL)
case .emptyAddress:
return ServerSelectionScreenViewModel(homeserverAddress: "",
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: true)
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL)
case .invalidAddress:
let viewModel = ServerSelectionScreenViewModel(homeserverAddress: "thisisbad",
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: true)
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL)
viewModel.displayError(.footerMessage(L10n.errorUnknown))
return viewModel
case .nonModal:
return ServerSelectionScreenViewModel(homeserverAddress: "https://matrix.org",
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: false)
}
}
}
@@ -11,9 +11,9 @@ import SwiftUI
struct ServerSelectionScreenCoordinatorParameters {
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let slidingSyncLearnMoreURL: URL
let userIndicatorController: UserIndicatorControllerProtocol
/// Whether the screen is presented modally or within a navigation stack.
let isModallyPresented: Bool
}
enum ServerSelectionScreenCoordinatorAction {
@@ -38,8 +38,7 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
init(parameters: ServerSelectionScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = ServerSelectionScreenViewModel(homeserverAddress: parameters.authenticationService.homeserver.value.address,
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: parameters.isModallyPresented)
slidingSyncLearnMoreURL: parameters.slidingSyncLearnMoreURL)
userIndicatorController = parameters.userIndicatorController
}
@@ -85,7 +84,7 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
startLoading()
Task {
switch await authenticationService.configure(for: homeserverAddress) {
switch await authenticationService.configure(for: homeserverAddress, flow: parameters.authenticationFlow) {
case .success:
MXLog.info("Selected homeserver: \(homeserverAddress)")
actionsSubject.send(.updated)
@@ -107,6 +106,8 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
viewModel.displayError(.invalidWellKnownAlert(error))
case .slidingSyncNotAvailable:
viewModel.displayError(.slidingSyncAlert)
case .registrationNotSupported:
viewModel.displayError(.registrationAlert) // TODO: [DOUG] Test me!
default:
viewModel.displayError(.footerMessage(L10n.errorUnknown))
}
@@ -22,19 +22,12 @@ struct ServerSelectionScreenViewState: BindableState {
var bindings: ServerSelectionScreenBindings
/// An error message to be shown in the text field footer.
var footerErrorMessage: String?
/// Whether the screen is presented modally or within a navigation stack.
var isModallyPresented: Bool
/// The message to show in the text field footer.
var footerMessage: AttributedString {
footerErrorMessage.map(AttributedString.init) ?? regularFooterMessage
}
/// The title shown on the confirm button.
var buttonTitle: String {
isModallyPresented ? L10n.actionContinue : L10n.actionNext
}
/// The text field is showing an error.
var isShowingFooterError: Bool {
footerErrorMessage != nil
@@ -45,10 +38,9 @@ struct ServerSelectionScreenViewState: BindableState {
bindings.homeserverAddress.isEmpty || isShowingFooterError
}
init(slidingSyncLearnMoreURL: URL, bindings: ServerSelectionScreenBindings, footerErrorMessage: String? = nil, isModallyPresented: Bool) {
init(slidingSyncLearnMoreURL: URL, bindings: ServerSelectionScreenBindings, footerErrorMessage: String? = nil) {
self.bindings = bindings
self.footerErrorMessage = footerErrorMessage
self.isModallyPresented = isModallyPresented
let linkPlaceholder = "{link}"
var message = AttributedString(L10n.screenChangeServerFormNotice(linkPlaceholder))
@@ -82,4 +74,6 @@ enum ServerSelectionScreenErrorType: Hashable {
case invalidWellKnownAlert(String)
/// An alert that allows the user to learn about sliding sync.
case slidingSyncAlert
/// An alert that informs the user that registration isn't supported.
case registrationAlert
}
@@ -19,13 +19,12 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
actionsSubject.eraseToAnyPublisher()
}
init(homeserverAddress: String, slidingSyncLearnMoreURL: URL, isModallyPresented: Bool) {
init(homeserverAddress: String, slidingSyncLearnMoreURL: URL) {
self.slidingSyncLearnMoreURL = slidingSyncLearnMoreURL
let bindings = ServerSelectionScreenBindings(homeserverAddress: homeserverAddress)
super.init(initialViewState: ServerSelectionScreenViewState(slidingSyncLearnMoreURL: slidingSyncLearnMoreURL,
bindings: bindings,
isModallyPresented: isModallyPresented))
bindings: bindings))
}
override func process(viewAction: ServerSelectionScreenViewAction) {
@@ -46,7 +45,7 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
state.footerErrorMessage = message
}
case .invalidWellKnownAlert(let error):
state.bindings.alertInfo = AlertInfo(id: .slidingSyncAlert,
state.bindings.alertInfo = AlertInfo(id: .invalidWellKnownAlert(error),
title: L10n.commonServerNotSupported,
message: L10n.screenChangeServerErrorInvalidWellKnown(error))
case .slidingSyncAlert:
@@ -56,6 +55,10 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
message: L10n.screenChangeServerErrorNoSlidingSyncMessage,
primaryButton: .init(title: L10n.actionLearnMore, role: .cancel, action: openURL),
secondaryButton: .init(title: L10n.actionCancel, action: nil))
case .registrationAlert:
state.bindings.alertInfo = AlertInfo(id: .registrationAlert,
title: L10n.errorUnknown,
message: L10n.errorAccountCreationNotPossible)
}
}
@@ -64,7 +64,7 @@ struct ServerSelectionScreen: View {
.onSubmit(submit)
Button(action: submit) {
Text(context.viewState.buttonTitle)
Text(L10n.actionContinue)
}
.buttonStyle(.compound(.primary))
.disabled(context.viewState.hasValidationError)
@@ -72,15 +72,12 @@ struct ServerSelectionScreen: View {
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
if context.viewState.isModallyPresented {
Button { context.send(viewAction: .dismiss) } label: {
Text(L10n.actionCancel)
}
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.dismiss)
Button { context.send(viewAction: .dismiss) } label: {
Text(L10n.actionCancel)
}
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.dismiss)
}
}