From 30b1c735cee901d4c4b09dd5345c31038598a711 Mon Sep 17 00:00:00 2001 From: wtimme Date: Mon, 23 May 2022 21:43:35 +0200 Subject: [PATCH 01/30] Prevent warnings from 3rd-party code to show up in Xcode The warnings from 3rd-party code can mostly be ignored, because it is not under the control of our team. In Xcode, this allows us to focus on the issues that we are able to fix. --- Podfile | 3 +++ Podfile.lock | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Podfile b/Podfile index c9b00799b..7384c594f 100644 --- a/Podfile +++ b/Podfile @@ -3,6 +3,9 @@ source 'https://cdn.cocoapods.org/' # Uncomment this line to define a global platform for your project platform :ios, '14.0' +# By default, ignore all warnings from any pod +inhibit_all_warnings! + # Use frameworks to allow usage of pods written in Swift use_frameworks! diff --git a/Podfile.lock b/Podfile.lock index 18695f229..4d2f005fe 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -229,6 +229,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 0113d1e88340b74fd30fdfb68abbb621724954e5 +PODFILE CHECKSUM: 96d138eb9be3e2c6be6fe2ac2287bff51420c6ae -COCOAPODS: 1.11.2 +COCOAPODS: 1.11.3 From 4aa7f27511e451c4bb2c87b3e98eac6fc26c4af1 Mon Sep 17 00:00:00 2001 From: wtimme Date: Mon, 23 May 2022 21:44:44 +0200 Subject: [PATCH 02/30] Remove redundant "inhibit warnings" direction Since all warnings are inhibited by default, there is no need to manually set this parameter here to `true`. --- Podfile | 5 ++--- Podfile.lock | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Podfile b/Podfile index 7384c594f..52b3a2866 100644 --- a/Podfile +++ b/Podfile @@ -75,9 +75,8 @@ abstract_target 'RiotPods' do pod 'AnalyticsEvents', :git => 'https://github.com/matrix-org/matrix-analytics-events.git', :branch => 'release/swift' # pod 'AnalyticsEvents', :path => '../matrix-analytics-events/AnalyticsEvents.podspec' - # Remove warnings from "bad" pods - pod 'OLMKit', :inhibit_warnings => true - pod 'zxcvbn-ios', :inhibit_warnings => true + pod 'OLMKit' + pod 'zxcvbn-ios' # Tools pod 'SwiftGen', '~> 6.3' diff --git a/Podfile.lock b/Podfile.lock index 4d2f005fe..77a15243d 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -229,6 +229,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 96d138eb9be3e2c6be6fe2ac2287bff51420c6ae +PODFILE CHECKSUM: ae5f22f20d68ccfcf8fc5693696ccbeaf55a7d34 COCOAPODS: 1.11.3 From 5bbd3972877da42fbfb28a617b630270291a4f4a Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 20 May 2022 11:14:17 +0100 Subject: [PATCH 03/30] Add login screen. --- Riot/Assets/en.lproj/Untranslated.strings | 10 +- Riot/Categories/UIDevice.swift | 4 + Riot/Generated/UntranslatedStrings.swift | 32 ++- .../AuthenticationCoordinator.swift | 49 +++- .../Onboarding/OnboardingCoordinator.swift | 6 +- .../AuthenticationSSOButton.swift | 0 .../AuthenticationServerInfoSection.swift | 65 +++++ .../Login/AuthenticationLoginModels.swift | 90 +++++++ .../Login/AuthenticationLoginViewModel.swift | 80 ++++++ ...AuthenticationLoginViewModelProtocol.swift | 33 +++ .../AuthenticationLoginCoordinator.swift | 231 ++++++++++++++++++ .../MockAuthenticationLoginScreenState.swift | 67 +++++ .../Test/UI/AuthenticationLoginUITests.swift | 44 ++++ .../AuthenticationLoginViewModelTests.swift | 48 ++++ .../View/AuthenticationLoginScreen.swift | 172 +++++++++++++ .../AuthenticationRegistrationModels.swift | 2 +- ...uthenticationRegistrationCoordinator.swift | 18 +- .../AuthenticationRegistrationScreen.swift | 32 +-- .../OnboardingSplashScreenCoordinator.swift | 30 ++- 19 files changed, 943 insertions(+), 70 deletions(-) rename RiotSwiftUI/Modules/Authentication/{Registration/View => Common}/AuthenticationSSOButton.swift (100%) create mode 100644 RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift create mode 100644 RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift create mode 100644 RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift create mode 100644 RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift create mode 100644 RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index 1b9b8518b..49ddc2243 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -23,13 +23,17 @@ // MARK: Onboarding Authentication WIP "authentication_registration_title" = "Create your account"; "authentication_registration_message" = "We’ll need some info to get you set up."; -"authentication_registration_server_title" = "Choose your server to store your data"; -"authentication_registration_matrix_description" = "Join millions for free on the largest public server"; "authentication_registration_username" = "Username"; -"authentication_registration_password" = "Password"; "authentication_registration_username_footer" = "You can’t change this later"; "authentication_registration_password_footer" = "Must be 8 characters or more"; +"authentication_login_title" = "Welcome back!"; +"authentication_login_username" = "Username or Email"; +"authentication_login_forgot_password" = "Forgot password"; + +"authentication_server_info_title" = "Choose your server to store your data"; +"authentication_server_info_matrix_description" = "Join millions for free on the largest public server"; + "authentication_server_selection_title" = "Choose your server"; "authentication_server_selection_message" = "What is the address of your server? A server is like a home for all your data."; "authentication_server_selection_server_url" = "Server URL"; diff --git a/Riot/Categories/UIDevice.swift b/Riot/Categories/UIDevice.swift index 093a8bdee..c3405e8ed 100644 --- a/Riot/Categories/UIDevice.swift +++ b/Riot/Categories/UIDevice.swift @@ -34,4 +34,8 @@ import UIKit return userInterfaceIdiom == .phone } + var initialDisplayName: String { + isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice + } + } diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index ce6960ade..a78be54f4 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -14,30 +14,30 @@ public extension VectorL10n { static var authenticationCancelFlowConfirmationMessage: String { return VectorL10n.tr("Untranslated", "authentication_cancel_flow_confirmation_message") } + /// Forgot password + static var authenticationLoginForgotPassword: String { + return VectorL10n.tr("Untranslated", "authentication_login_forgot_password") + } + /// Welcome back! + static var authenticationLoginTitle: String { + return VectorL10n.tr("Untranslated", "authentication_login_title") + } + /// Username or Email + static var authenticationLoginUsername: String { + return VectorL10n.tr("Untranslated", "authentication_login_username") + } /// This server would like to make sure you are not a robot static var authenticationRecaptchaMessage: String { return VectorL10n.tr("Untranslated", "authentication_recaptcha_message") } - /// Join millions for free on the largest public server - static var authenticationRegistrationMatrixDescription: String { - return VectorL10n.tr("Untranslated", "authentication_registration_matrix_description") - } /// We’ll need some info to get you set up. static var authenticationRegistrationMessage: String { return VectorL10n.tr("Untranslated", "authentication_registration_message") } - /// Password - static var authenticationRegistrationPassword: String { - return VectorL10n.tr("Untranslated", "authentication_registration_password") - } /// Must be 8 characters or more static var authenticationRegistrationPasswordFooter: String { return VectorL10n.tr("Untranslated", "authentication_registration_password_footer") } - /// Choose your server to store your data - static var authenticationRegistrationServerTitle: String { - return VectorL10n.tr("Untranslated", "authentication_registration_server_title") - } /// Create your account static var authenticationRegistrationTitle: String { return VectorL10n.tr("Untranslated", "authentication_registration_title") @@ -50,6 +50,14 @@ public extension VectorL10n { static var authenticationRegistrationUsernameFooter: String { return VectorL10n.tr("Untranslated", "authentication_registration_username_footer") } + /// Join millions for free on the largest public server + static var authenticationServerInfoMatrixDescription: String { + return VectorL10n.tr("Untranslated", "authentication_server_info_matrix_description") + } + /// Choose your server to store your data + static var authenticationServerInfoTitle: String { + return VectorL10n.tr("Untranslated", "authentication_server_info_title") + } /// Cannot find a server at this URL, please check it is correct. static var authenticationServerSelectionGenericError: String { return VectorL10n.tr("Untranslated", "authentication_server_selection_generic_error") diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index 80829fae7..f79e1d5ef 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -144,6 +144,43 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc callback?(.cancel(.register)) } + // MARK: - Login + + /// Shows the login screen. + @MainActor private func showLoginScreen() { + MXLog.debug("[AuthenticationCoordinator] showLoginScreen") + + let homeserver = authenticationService.state.homeserver + let parameters = AuthenticationLoginCoordinatorParameters(navigationRouter: navigationRouter, + authenticationService: authenticationService, + loginMode: homeserver.preferredLoginMode) + let coordinator = AuthenticationLoginCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.loginCoordinator(coordinator, didCompleteWith: result) + } + + coordinator.start() + add(childCoordinator: coordinator) + + if navigationRouter.modules.isEmpty { + navigationRouter.setRootModule(coordinator, popCompletion: nil) + } else { + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + } + + /// Displays the next view in the flow after the registration screen. + @MainActor private func loginCoordinator(_ coordinator: AuthenticationLoginCoordinator, + didCompleteWith result: AuthenticationLoginCoordinatorResult) { + switch result { + case .success(let session): + onSessionCreated(session: session, flow: .login) + } + } + // MARK: - Registration /// Pushes the server selection screen into the flow (other screens may also present it modally later). @@ -298,12 +335,6 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } } - /// Shows the login screen. - @MainActor private func showLoginScreen() { - MXLog.debug("[AuthenticationCoordinator] showLoginScreen") - - } - // MARK: - Registration Handlers /// Determines the next screen to show from the flow result and pushes it. @MainActor private func handleRegistrationResult(_ result: RegistrationResult) { @@ -378,7 +409,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc verificationListener.start() self.verificationListener = verificationListener - #warning("Add authentication type to the new flow") + #warning("Add authentication type to the new flow.") callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: .other)) } @@ -397,7 +428,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc /// Present the key verification screen modally. private func presentCompleteSecurity() { guard let session = session else { - MXLog.error("[LegacyAuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") + MXLog.error("[AuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") authenticationDidComplete() return } @@ -427,7 +458,7 @@ extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { if let crypto = session?.crypto, !crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled { - MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") + MXLog.debug("[AuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) } diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 37882bec5..2a7996d0c 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -163,7 +163,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { case .register: showUseCaseSelectionScreen() case .login: - showLegacyAuthenticationScreen() + beginAuthentication(with: .login, onStart: coordinator.stop) } } @@ -232,6 +232,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { self.cancelAuthentication(flow: flow) } } + authenticationCoordinator = coordinator add(childCoordinator: coordinator) coordinator.start() @@ -256,7 +257,6 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // These results are only sent by the new flow. break } - } // Due to needing to preload the authVC, this breaks the Coordinator init/start pattern. @@ -567,7 +567,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } guard authenticationFinished else { - MXLog.debug("[OnboardingCoordinator] Allowing LegacyAuthenticationCoordinator to display any remaining screens.") + MXLog.debug("[OnboardingCoordinator] Allowing AuthenticationCoordinator to display any remaining screens.") authenticationCoordinator.presentPendingScreensIfNecessary() return } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift similarity index 100% rename from RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift rename to RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift new file mode 100644 index 000000000..63f5642f3 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift @@ -0,0 +1,65 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct AuthenticationServerInfoSection: View { + + // MARK: - Private + + @Environment(\.theme) private var theme + + // MARK: - Public + + let address: String + let description: String? + let editAction: () -> Void + + // MARK: - Views + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(VectorL10n.authenticationServerInfoTitle) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors.secondaryContent) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(address) + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + + if let description = description { + Text(description) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.tertiaryContent) + .accessibilityIdentifier("serverDescriptionText") + } + } + + Spacer() + + Button(action: editAction) { + Text(VectorL10n.edit) + .font(theme.fonts.body) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent)) + } + } + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift new file mode 100644 index 000000000..c3ca7ea45 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -0,0 +1,90 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: View model + +enum AuthenticationLoginViewModelResult { + /// The user would like to select another server. + case selectServer + /// 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) +} + +// MARK: View + +struct AuthenticationLoginViewState: BindableState { + /// The address of the homeserver. + var homeserverAddress: String + /// Whether or not to show the username and password text fields with the next button + var showLoginForm: Bool + /// An array containing the available SSO options for login. + var ssoIdentityProviders: [SSOIdentityProvider] + /// View state that can be bound to from SwiftUI. + var bindings: AuthenticationLoginBindings + + /// A description that can be shown for the currently selected homeserver. + var serverDescription: String? { + guard homeserverAddress == "matrix.org" else { return nil } + return VectorL10n.authenticationServerInfoMatrixDescription + } + + /// Whether to show any SSO buttons. + var showSSOButtons: Bool { + !ssoIdentityProviders.isEmpty + } + + /// `true` if it is possible to continue, otherwise `false`. + var hasValidCredentials: Bool { + !bindings.username.isEmpty && !bindings.password.isEmpty + } +} + +struct AuthenticationLoginBindings { + /// The username input by the user. + var username = "" + /// The password input by the user. + var password = "" + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? +} + +enum AuthenticationLoginViewAction { + /// The user would like to select another server. + case selectServer + /// 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 + /// Login using the supplied SSO provider ID. + case continueWithSSO(id: String) +} + +enum AuthenticationLoginErrorType: Hashable { + /// An error response from the homeserver. + case mxError(String) + /// The current homeserver address isn't valid. + case invalidHomeserver + /// The response from the homeserver was unexpected. + case unknown +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift new file mode 100644 index 000000000..aab6edbc2 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -0,0 +1,80 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +typealias AuthenticationLoginViewModelType = StateStoreViewModel + +class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, AuthenticationLoginViewModelProtocol { + + // MARK: - Properties + + // MARK: Public + + @MainActor var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? + + // MARK: - Setup + + init(homeserverAddress: String, showLoginForm: Bool = true, ssoIdentityProviders: [SSOIdentityProvider]) { + let bindings = AuthenticationLoginBindings() + let viewState = AuthenticationLoginViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress), + showLoginForm: showLoginForm, + ssoIdentityProviders: ssoIdentityProviders, + bindings: bindings) + + super.init(initialViewState: viewState) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationLoginViewAction) { + switch viewAction { + case .selectServer: + Task { await callback?(.selectServer) } + case .parseUsername: + Task { await callback?(.parseUsername(state.bindings.username)) } + case .forgotPassword: + Task { await callback?(.forgotPassword) } + case .next: + Task { await callback?(.login(username: state.bindings.username, password: state.bindings.password)) } + case .continueWithSSO(let id): + break + } + } + + @MainActor func update(homeserverAddress: String, showLoginForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) { + state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress) + state.showLoginForm = showLoginForm + state.ssoIdentityProviders = ssoIdentityProviders + } + + @MainActor func displayError(_ type: AuthenticationLoginErrorType) { + switch type { + case .mxError(let message): + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: message) + case .invalidHomeserver: + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: VectorL10n.authenticationServerSelectionGenericError) + case .unknown: + state.bindings.alertInfo = AlertInfo(id: type) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift new file mode 100644 index 000000000..a9552cf4e --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift @@ -0,0 +1,33 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol AuthenticationLoginViewModelProtocol { + + @MainActor var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? { get set } + var context: AuthenticationLoginViewModelType.Context { get } + + /// Update the view with new homeserver information. + /// - Parameters: + /// - homeserverAddress: The homeserver string to be shown to the user. + /// - showLoginForm: Whether or not to display the username and password text fields. + /// - ssoIdentityProviders: The supported SSO login options. + @MainActor func update(homeserverAddress: String, showLoginForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) + + /// Display an error to the user. + @MainActor func displayError(_ type: AuthenticationLoginErrorType) +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift new file mode 100644 index 000000000..e0aef7afa --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -0,0 +1,231 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import CommonKit +import MatrixSDK + +struct AuthenticationLoginCoordinatorParameters { + let navigationRouter: NavigationRouterType + let authenticationService: AuthenticationService + /// The login mode to allow SSO buttons to be shown when available. + let loginMode: LoginMode +} + +enum AuthenticationLoginCoordinatorResult { + /// Login was successful with the associated session created. + case success(MXSession) +} + +final class AuthenticationLoginCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationLoginCoordinatorParameters + private let authenticationLoginHostingController: VectorHostingController + private var authenticationLoginViewModel: AuthenticationLoginViewModelProtocol + + private var currentTask: Task? { + willSet { + currentTask?.cancel() + } + } + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var waitingIndicator: UserIndicator? + + /// The authentication service used for the login. + private var authenticationService: AuthenticationService { parameters.authenticationService } + /// The wizard used to handle the login flow. Will only be `nil` if there is a misconfiguration. + private var loginWizard: LoginWizard? { parameters.authenticationService.loginWizard } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + @MainActor var callback: ((AuthenticationLoginCoordinatorResult) -> Void)? + + // MARK: - Setup + + @MainActor init(parameters: AuthenticationLoginCoordinatorParameters) { + self.parameters = parameters + + let homeserver = parameters.authenticationService.state.homeserver + let viewModel = AuthenticationLoginViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, + showLoginForm: homeserver.preferredLoginMode.supportsPasswordFlow, + ssoIdentityProviders: parameters.loginMode.ssoIdentityProviders ?? []) + authenticationLoginViewModel = viewModel + + let view = AuthenticationLoginScreen(viewModel: viewModel.context) + authenticationLoginHostingController = VectorHostingController(rootView: view) + authenticationLoginHostingController.vc_removeBackTitle() + authenticationLoginHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationLoginHostingController) + } + + // MARK: - Public + func start() { + MXLog.debug("[AuthenticationLoginCoordinator] did start.") + Task { await setupViewModel() } + } + + func toPresentable() -> UIViewController { + authenticationLoginHostingController + } + + // MARK: - Private + + /// Set up the view model. This method is extracted from `start()` so it can run on the `MainActor`. + @MainActor private func setupViewModel() { + authenticationLoginViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationLoginCoordinator] AuthenticationLoginViewModel did callback with result: \(result).") + switch result { + case .selectServer: + self.presentServerSelectionScreen() + case .parseUsername(let username): + self.parseUsername(username) + case .forgotPassword: + #warning("Show the forgot password flow.") + case .login(let username, let password): + self.login(username: username, password: password) + } + } + } + + /// Show a blocking activity indicator whilst saving. + @MainActor private func startLoading(isInteractionBlocking: Bool) { + waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: isInteractionBlocking)) + } + + /// Hide the currently displayed activity indicator. + @MainActor private func stopLoading() { + waitingIndicator = nil + } + + /// Login with the supplied username and password. + @MainActor private func login(username: String, password: String) { + guard let loginWizard = loginWizard else { + MXLog.failure("[AuthenticationLoginCoordinator] The login wizard was requested before getting the login flow.") + return + } + + startLoading(isInteractionBlocking: true) + + currentTask = Task { [weak self] in + do { + let session = try await loginWizard.login(login: username, + password: password, + initialDeviceName: UIDevice.current.initialDisplayName) + + guard !Task.isCancelled else { return } + callback?(.success(session)) + + self?.stopLoading() + } catch { + self?.stopLoading() + self?.handleError(error) + } + } + } + + /// Processes an error to either update the flow or display it to the user. + @MainActor private func handleError(_ error: Error) { + if let mxError = MXError(nsError: error as NSError) { + authenticationLoginViewModel.displayError(.mxError(mxError.error)) + return + } + + if let authenticationError = error as? AuthenticationError { + switch authenticationError { + case .invalidHomeserver: + authenticationLoginViewModel.displayError(.invalidHomeserver) + case .loginFlowNotCalled: + #warning("Reset the flow") + case .missingMXRestClient: + #warning("Forget the soft logout session") + } + return + } + + authenticationLoginViewModel.displayError(.unknown) + } + + @MainActor private func parseUsername(_ username: String) { + guard MXTools.isMatrixUserIdentifier(username) else { return } + let domain = username.split(separator: ":")[1] + let homeserverAddress = HomeserverAddress.sanitized(String(domain)) + + startLoading(isInteractionBlocking: false) + + currentTask = Task { [weak self] in + do { + try await authenticationService.startFlow(.login, for: homeserverAddress) + + guard !Task.isCancelled else { return } + + updateViewModel() + self?.stopLoading() + } catch { + self?.stopLoading() + self?.handleError(error) + } + } + } + + /// Presents the server selection screen as a modal. + @MainActor private func presentServerSelectionScreen() { + MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") + let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, + hasModalPresentation: true) + let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.serverSelectionCoordinator(coordinator, didCompleteWith: result) + } + + coordinator.start() + add(childCoordinator: coordinator) + + let modalRouter = NavigationRouter() + modalRouter.setRootModule(coordinator) + + navigationRouter.present(modalRouter, animated: true) + } + + /// Handles the result from the server selection modal, dismissing it after updating the view. + @MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator, + didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) { + if result == .updated { + updateViewModel() + } + + navigationRouter.dismissModule(animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + @MainActor private func updateViewModel() { + let homeserver = authenticationService.state.homeserver + authenticationLoginViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, + showLoginForm: homeserver.preferredLoginMode.supportsPasswordFlow, + ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? []) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift new file mode 100644 index 000000000..6b42c3856 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift @@ -0,0 +1,67 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationLoginScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case matrixDotOrg + case passwordOnly + case passwordWithCredentials + case ssoOnly + + /// The associated screen + var screenType: Any.Type { + AuthenticationLoginScreen.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationLoginViewModel + switch self { + case .matrixDotOrg: + viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://matrix.org", ssoIdentityProviders: [ + SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil), + SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil), + SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil), + SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil), + SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil) + ]) + case .passwordOnly: + viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + case .passwordWithCredentials: + viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel.context.username = "alice" + viewModel.context.password = "password" + case .ssoOnly: + viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://company.com", + showLoginForm: false, + ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) + } + + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], AnyView(AuthenticationLoginScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift new file mode 100644 index 000000000..243f40cce --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift @@ -0,0 +1,44 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +class AuthenticationLoginUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockAuthenticationLoginScreenState.self + } + + override class func createTest() -> MockScreenTest { + return AuthenticationLoginUITests(selector: #selector(verifyAuthenticationLoginScreen)) + } + + func verifyAuthenticationLoginScreen() throws { + guard let screenState = screenState as? MockAuthenticationLoginScreenState else { fatalError("no screen") } + switch screenState { + case .promptType(let promptType): + verifyAuthenticationLoginPromptType(promptType: promptType) + } + } + + func verifyAuthenticationLoginPromptType(promptType: AuthenticationLoginPromptType) { + let title = app.staticTexts["title"] + XCTAssert(title.exists) + XCTAssertEqual(title.label, promptType.title) + } + +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift new file mode 100644 index 000000000..2635c4325 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift @@ -0,0 +1,48 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import RiotSwiftUI + +class AuthenticationLoginViewModelTests: XCTestCase { + private enum Constants { + static let counterInitialValue = 0 + } + + var viewModel: AuthenticationLoginViewModelProtocol! + var context: AuthenticationLoginViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationLoginViewModel(promptType: .regular, initialCount: Constants.counterInitialValue) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.count, Constants.counterInitialValue) + } + + func testCounter() throws { + context.send(viewAction: .incrementCount) + XCTAssertEqual(context.viewState.count, 1) + + context.send(viewAction: .incrementCount) + XCTAssertEqual(context.viewState.count, 2) + + context.send(viewAction: .decrementCount) + XCTAssertEqual(context.viewState.count, 1) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift new file mode 100644 index 000000000..47ef0845d --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -0,0 +1,172 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct AuthenticationLoginScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + /// A boolean that can be toggled to give focus to the password text field. + /// This must be manually set back to `false` when the text field finishes editing. + @State private var isPasswordFocused = false + + // MARK: Public + + @ObservedObject var viewModel: AuthenticationLoginViewModel.Context + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + .padding(.bottom, 36) + + serverInfo + .padding(.leading, 12) + + Rectangle() + .fill(theme.colors.quinaryContent) + .frame(height: 1) + .padding(.vertical, 21) + + if viewModel.viewState.showLoginForm { + loginForm + } + + if viewModel.viewState.showLoginForm && viewModel.viewState.showSSOButtons { + Text(VectorL10n.or) + .foregroundColor(theme.colors.secondaryContent) + .padding(.top, 16) + } + + if viewModel.viewState.showSSOButtons { + ssoButtons + .padding(.top, 16) + } + + } + .readableFrame() + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + .alert(item: $viewModel.alertInfo) { $0.alert } + .accentColor(theme.colors.accent) + } + + /// The header containing the icon, title and message. + var header: some View { + Text(VectorL10n.authenticationLoginTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + } + + /// The sever information section that includes a button to select a different server. + var serverInfo: some View { + AuthenticationServerInfoSection(address: viewModel.viewState.homeserverAddress, + description: viewModel.viewState.serverDescription) { + viewModel.send(viewAction: .selectServer) + } + } + + /// The form with text fields for username and password, along with a submit button. + var loginForm: some View { + VStack(spacing: 14) { + RoundedBorderTextField(placeHolder: VectorL10n.authenticationLoginUsername, + text: $viewModel.username, + isFirstResponder: false, + configuration: UIKitTextInputConfiguration(returnKeyType: .next, + autocapitalizationType: .none, + autocorrectionType: .no), + onEditingChanged: usernameEditingChanged) + .accessibilityIdentifier("usernameTextField") + + Spacer().frame(height: 20) + + RoundedBorderTextField(placeHolder: VectorL10n.authPasswordPlaceholder, + text: $viewModel.password, + isFirstResponder: isPasswordFocused, + configuration: UIKitTextInputConfiguration(returnKeyType: .done, + isSecureTextEntry: true), + onEditingChanged: passwordEditingChanged) + .accessibilityIdentifier("passwordTextField") + + Button { } label: { + Text(VectorL10n.authenticationLoginForgotPassword) + .font(theme.fonts.body) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.bottom, 8) + + Button(action: submit) { + Text(VectorL10n.next) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(!viewModel.viewState.hasValidCredentials) + .accessibilityIdentifier("nextButton") + } + } + + /// A list of SSO buttons that can be used for login. + var ssoButtons: some View { + VStack(spacing: 16) { + ForEach(viewModel.viewState.ssoIdentityProviders) { provider in + AuthenticationSSOButton(provider: provider) { + viewModel.send(viewAction: .continueWithSSO(id: provider.id)) + } + .accessibilityIdentifier("ssoButton") + } + } + } + + /// Give focus to the password text field. + func usernameEditingChanged(isEditing: Bool) { + guard !isEditing, !viewModel.username.isEmpty else { return } + + viewModel.send(viewAction: .parseUsername) + isPasswordFocused = true + } + + /// Submits the form if valid credentials have been input. + func passwordEditingChanged(isEditing: Bool) { + guard !isEditing else { return } + isPasswordFocused = false + submit() + } + + /// Sends the `next` view action so long as valid credentials have been input. + func submit() { + guard viewModel.viewState.hasValidCredentials else { return } + viewModel.send(viewAction: .next) + } +} + +// MARK: - Previews + +@available(iOS 15.0, *) +struct AuthenticationLogin_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationLoginScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift index da72ac4eb..c86f20f32 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift @@ -58,7 +58,7 @@ struct AuthenticationRegistrationViewState: BindableState { /// A description that can be shown for the currently selected homeserver. var serverDescription: String? { guard homeserverAddress == "matrix.org" else { return nil } - return VectorL10n.authenticationRegistrationMatrixDescription + return VectorL10n.authenticationServerInfoMatrixDescription } /// Whether to show any SSO buttons. diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index 1cbfffdf5..3dfe52ac3 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -53,9 +53,9 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { private var waitingIndicator: UserIndicator? /// The authentication service used for the registration. - var authenticationService: AuthenticationService { parameters.authenticationService } + private var authenticationService: AuthenticationService { parameters.authenticationService } /// The wizard used to handle the registration flow. May be `nil` when only SSO is supported. - var registrationWizard: RegistrationWizard? + private var registrationWizard: RegistrationWizard? { parameters.authenticationService.registrationWizard } // MARK: Public @@ -67,7 +67,6 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { @MainActor init(parameters: AuthenticationRegistrationCoordinatorParameters) { self.parameters = parameters - self.registrationWizard = parameters.authenticationService.registrationWizard let homeserver = parameters.authenticationService.state.homeserver let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, @@ -112,8 +111,8 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { } /// Show a blocking activity indicator whilst saving. - @MainActor private func startLoading(label: String? = nil) { - waitingIndicator = indicatorPresenter.present(.loading(label: label ?? VectorL10n.loading, isInteractionBlocking: true)) + @MainActor private func startLoading() { + waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) } /// Hide the currently displayed activity indicator. @@ -149,14 +148,13 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { return } - // reAuthHelper.data = state.password - let deviceDisplayName = UIDevice.current.isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice - startLoading() currentTask = Task { [weak self] in do { - let result = try await registrationWizard.createAccount(username: username, password: password, initialDeviceDisplayName: deviceDisplayName) + let result = try await registrationWizard.createAccount(username: username, + password: password, + initialDeviceDisplayName: UIDevice.current.initialDisplayName) guard !Task.isCancelled else { return } callback?(.completed(result)) @@ -230,8 +228,6 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, showRegistrationForm: homeserver.registrationFlow != nil, ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? []) - - self.registrationWizard = authenticationService.registrationWizard } navigationRouter.dismissModule(animated: true) { [weak self] in diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift index bd645f003..b0c137ceb 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -90,35 +90,9 @@ struct AuthenticationRegistrationScreen: View { /// The sever information section that includes a button to select a different server. var serverInfo: some View { - VStack(alignment: .leading, spacing: 4) { - Text(VectorL10n.authenticationRegistrationServerTitle) - .font(theme.fonts.subheadline) - .foregroundColor(theme.colors.secondaryContent) - - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(viewModel.viewState.homeserverAddress) - .font(theme.fonts.body) - .foregroundColor(theme.colors.primaryContent) - - if let serverDescription = viewModel.viewState.serverDescription { - Text(serverDescription) - .font(theme.fonts.caption1) - .foregroundColor(theme.colors.tertiaryContent) - .accessibilityIdentifier("serverDescriptionText") - } - } - - Spacer() - - Button { viewModel.send(viewAction: .selectServer) } label: { - Text(VectorL10n.edit) - .font(theme.fonts.body) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent)) - } - } + AuthenticationServerInfoSection(address: viewModel.viewState.homeserverAddress, + description: viewModel.viewState.serverDescription) { + viewModel.send(viewAction: .selectServer) } } diff --git a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift index e269e55e2..534bb4e64 100644 --- a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift @@ -15,6 +15,7 @@ // import SwiftUI +import CommonKit protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable { var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set } @@ -29,6 +30,9 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator private let onboardingSplashScreenHostingController: VectorHostingController private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + // MARK: Public // Must be used only internally @@ -43,6 +47,8 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator onboardingSplashScreenViewModel = viewModel onboardingSplashScreenHostingController = VectorHostingController(rootView: view) onboardingSplashScreenHostingController.vc_removeBackTitle() + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingSplashScreenHostingController) } // MARK: - Public @@ -52,13 +58,33 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator MXLog.debug("[OnboardingSplashScreenCoordinator] OnboardingSplashScreenViewModel did complete with result: \(result).") guard let self = self else { return } switch result { - case .login, .register: + case .login: + self.startLoading() + self.completion?(result) + case .register: self.completion?(result) } } } func toPresentable() -> UIViewController { - return self.onboardingSplashScreenHostingController + return onboardingSplashScreenHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil } } From afc2c9f1c53a9e62fb08fa469c2bc1319220ea31 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 24 May 2022 12:26:39 +0100 Subject: [PATCH 04/30] Fix presentation of verification with the new login flow. --- .../Onboarding/OnboardingCoordinator.swift | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 2a7996d0c..4eae6588c 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -54,8 +54,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { private var navigationRouter: NavigationRouterType { parameters.router } - // Keep a strong ref as we need to init authVC early to preload its view - private let authenticationCoordinator: AuthenticationCoordinatorProtocol + /// A strong ref to the legacy authVC as we need to init early to preload its view. + private let legacyAuthenticationCoordinator: LegacyAuthenticationCoordinator + /// The currently active authentication coordinator, otherwise `nil`. + private weak var authenticationCoordinator: AuthenticationCoordinatorProtocol? #warning("This might be removable when SSO comes through the AuthenticationService?") /// A boolean to prevent authentication being shown when already in progress. private var isShowingLegacyAuthentication = false @@ -90,9 +92,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { init(parameters: OnboardingCoordinatorParameters) { self.parameters = parameters - // Preload the authVC (it is *really* slow to load in realtime) + // Preload the legacy authVC (it is *really* slow to load in realtime) let authenticationParameters = LegacyAuthenticationCoordinatorParameters(navigationRouter: parameters.router, canPresentAdditionalScreens: false) - authenticationCoordinator = LegacyAuthenticationCoordinator(parameters: authenticationParameters) + legacyAuthenticationCoordinator = LegacyAuthenticationCoordinator(parameters: authenticationParameters) super.init() } @@ -116,20 +118,20 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { /// For more information see `AuthenticationViewController.externalRegistrationParameters`. func update(externalRegistrationParameters: [AnyHashable: Any]) { self.externalRegistrationParameters = externalRegistrationParameters - authenticationCoordinator.update(externalRegistrationParameters: externalRegistrationParameters) + legacyAuthenticationCoordinator.update(externalRegistrationParameters: externalRegistrationParameters) } /// Set up the authentication screen with the specified homeserver and/or identity server. func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?) { self.customHomeserver = homeserver self.customIdentityServer = identityServer - authenticationCoordinator.updateHomeserver(homeserver, andIdentityServer: identityServer) + legacyAuthenticationCoordinator.updateHomeserver(homeserver, andIdentityServer: identityServer) } /// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters. func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool { guard isShowingLegacyAuthentication else { return false } - return authenticationCoordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID) + return legacyAuthenticationCoordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID) } // MARK: - Pre-Authentication @@ -156,14 +158,19 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { private func splashScreenCoordinator(_ coordinator: OnboardingSplashScreenCoordinator, didCompleteWith result: OnboardingSplashScreenViewModelResult) { splashScreenResult = result - // Set the auth type early to allow network requests to finish during display of the use case screen. - authenticationCoordinator.update(authenticationFlow: result.flow) + // Set the auth type early on the legacy auth to allow network requests to finish during display of the use case screen. + legacyAuthenticationCoordinator.update(authenticationFlow: result.flow) switch result { case .register: showUseCaseSelectionScreen() case .login: - beginAuthentication(with: .login, onStart: coordinator.stop) + if BuildSettings.onboardingEnableNewAuthenticationFlow { + beginAuthentication(with: .login, onStart: coordinator.stop) + } else { + coordinator.stop() + showLegacyAuthenticationScreen() + } } } @@ -244,7 +251,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { MXLog.debug("[OnboardingCoordinator] showLegacyAuthenticationScreen") - let coordinator = authenticationCoordinator + let coordinator = legacyAuthenticationCoordinator coordinator.callback = { [weak self, weak coordinator] result in guard let self = self, let coordinator = coordinator else { return } @@ -272,6 +279,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { coordinator.update(softLogoutCredentials: softLogoutCredentials) } + authenticationCoordinator = coordinator + coordinator.start() add(childCoordinator: coordinator) @@ -567,6 +576,11 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } guard authenticationFinished else { + guard let authenticationCoordinator = authenticationCoordinator else { + MXLog.failure("[OnboardingCoordinator] completeIfReady: authenticationCoordinator is missing.") + return + } + MXLog.debug("[OnboardingCoordinator] Allowing AuthenticationCoordinator to display any remaining screens.") authenticationCoordinator.presentPendingScreensIfNecessary() return From 26b4130eb1bbd746731ecf8680b25b25b60ed67b Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 24 May 2022 13:10:13 +0100 Subject: [PATCH 05/30] Make the server selection coordinator aware of which flow it is for. Don't set the authentication state until after registration has been queried to avoid using the wrong client if the user cancelled the selection. --- .../AuthenticationCoordinator.swift | 1 + .../MatrixSDK/AuthenticationService.swift | 37 ++++++++----------- .../MatrixSDK/AuthenticationState.swift | 5 +++ .../AuthenticationLoginCoordinator.swift | 1 + ...uthenticationRegistrationCoordinator.swift | 1 + ...enticationServerSelectionCoordinator.swift | 6 +-- 6 files changed, 26 insertions(+), 25 deletions(-) diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index f79e1d5ef..355cec9a4 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -187,6 +187,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc @MainActor private func showServerSelectionScreen() { MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, + flow: .register, hasModalPresentation: false) let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters) coordinator.callback = { [weak self, weak coordinator] result in diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 90cd992f4..4251cab79 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -84,14 +84,7 @@ class AuthenticationService: NSObject { } func startFlow(_ flow: AuthenticationFlow, for homeserverAddress: String) async throws { - reset() - - let loginFlows = try await loginFlow(for: homeserverAddress) - - state.homeserver = .init(address: loginFlows.homeserverAddress, - addressFromUser: homeserverAddress, - preferredLoginMode: loginFlows.loginMode, - loginModeSupportedTypes: loginFlows.supportedLoginTypes) + var (client, state) = try await loginFlow(for: homeserverAddress) let loginWizard = LoginWizard(client: client) self.loginWizard = loginWizard @@ -100,6 +93,7 @@ class AuthenticationService: NSObject { do { let registrationWizard = RegistrationWizard(client: client) state.homeserver.registrationFlow = try await registrationWizard.registrationFlow() + state.flow = .register self.registrationWizard = registrationWizard } catch { guard state.homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else { @@ -109,7 +103,8 @@ class AuthenticationService: NSObject { } } - state.flow = flow + self.state = state + self.client = client } /// Get a SSO url @@ -173,7 +168,7 @@ class AuthenticationService: NSObject { /// Request the supported login flows for this homeserver. /// This is the first method to call to be able to get a wizard to login or to create an account /// - Parameter homeserverAddress: The homeserver string entered by the user. - private func loginFlow(for homeserverAddress: String) async throws -> LoginFlowResult { + private func loginFlow(for homeserverAddress: String) async throws -> (AuthenticationRestClient, AuthenticationState) { let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress) guard var homeserverURL = URL(string: homeserverAddress) else { @@ -181,8 +176,6 @@ class AuthenticationService: NSObject { throw AuthenticationError.invalidHomeserver } - let state = AuthenticationState(flow: .login, homeserverAddress: homeserverAddress) - if let wellKnown = try? await wellKnown(for: homeserverURL), let baseURL = URL(string: wellKnown.homeServer.baseUrl) { homeserverURL = baseURL @@ -193,28 +186,28 @@ class AuthenticationService: NSObject { let loginFlow = try await getLoginFlowResult(client: client) - self.client = client - self.state = state - - return loginFlow + let state = AuthenticationState(flow: .login, homeserver: .init(address: loginFlow.homeserverAddress, + addressFromUser: homeserverAddress, + preferredLoginMode: loginFlow.loginMode, + loginModeSupportedTypes: loginFlow.supportedLoginTypes)) + return (client, state) } /// Request the supported login flows for the corresponding session. /// This method is used to get the flows for a server after a soft-logout. /// - Parameter session: The MXSession where a soft-logout has occurred. - private func loginFlow(for session: MXSession) async throws -> LoginFlowResult { + private func loginFlow(for session: MXSession) async throws -> (AuthenticationRestClient, AuthenticationState) { guard let client = session.matrixRestClient else { MXLog.error("[AuthenticationService] loginFlow called on a session that doesn't have a matrixRestClient.") throw AuthenticationError.missingMXRestClient } - let state = AuthenticationState(flow: .login, homeserverAddress: client.homeserver) let loginFlow = try await getLoginFlowResult(client: session.matrixRestClient) - self.client = client - self.state = state - - return loginFlow + let state = AuthenticationState(flow: .login, homeserver: .init(address: loginFlow.homeserverAddress, + preferredLoginMode: loginFlow.loginMode, + loginModeSupportedTypes: loginFlow.supportedLoginTypes)) + return (client, state) } private func getLoginFlowResult(client: MXRestClient) async throws -> LoginFlowResult { diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift index 0ce762aac..1e470e372 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift @@ -30,6 +30,11 @@ struct AuthenticationState { self.homeserver = Homeserver(address: homeserverAddress) } + init(flow: AuthenticationFlow, homeserver: Homeserver) { + self.flow = flow + self.homeserver = homeserver + } + struct Homeserver { /// The homeserver address as returned by the server. var address: String diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index e0aef7afa..b7f989876 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -194,6 +194,7 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { @MainActor private func presentServerSelectionScreen() { MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, + flow: .login, hasModalPresentation: true) let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters) coordinator.callback = { [weak self, weak coordinator] result in diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index 3dfe52ac3..4bfa45cd5 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -204,6 +204,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { @MainActor private func presentServerSelectionScreen() { MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, + flow: .register, hasModalPresentation: true) let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters) coordinator.callback = { [weak self, weak coordinator] result in diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift index 39efc554c..23a62f658 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift @@ -19,6 +19,8 @@ import CommonKit struct AuthenticationServerSelectionCoordinatorParameters { let authenticationService: AuthenticationService + /// Whether the server selection is for the login flow or registration flow. + let flow: AuthenticationFlow /// Whether the screen is presented modally or within a navigation stack. let hasModalPresentation: Bool } @@ -111,14 +113,12 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable { /// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible. @MainActor private func useHomeserver(_ homeserverAddress: String) { startLoading() - authenticationService.reset() let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress) Task { do { - #warning("The screen should be configuration for .login too.") - try await authenticationService.startFlow(.register, for: homeserverAddress) + try await authenticationService.startFlow(parameters.flow, for: homeserverAddress) stopLoading() callback?(.updated) From e77d7d9250f58e98538efb0f588fc3cd9ac665b5 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 24 May 2022 13:41:47 +0100 Subject: [PATCH 06/30] Add AuthenticationHomeserverViewData to easily map AuthenticationState.Homeserver to a type in the SwiftUI target. --- .../AuthenticationCoordinator.swift | 2 +- .../AuthenticationHomeserverViewData.swift | 75 +++++++++++++++++++ .../AuthenticationServerInfoSection.swift | 6 +- .../Common/HomeserverAddress.swift | 31 -------- .../MatrixSDK/AuthenticationService.swift | 6 +- .../MatrixSDK/AuthenticationState.swift | 15 +++- .../Login/AuthenticationLoginModels.swift | 16 +--- .../Login/AuthenticationLoginViewModel.swift | 13 +--- ...AuthenticationLoginViewModelProtocol.swift | 6 +- .../AuthenticationLoginCoordinator.swift | 9 +-- .../MockAuthenticationLoginScreenState.swift | 16 +--- .../View/AuthenticationLoginScreen.swift | 10 +-- .../AuthenticationRegistrationModels.swift | 16 +--- .../AuthenticationRegistrationViewModel.swift | 13 +--- ...icationRegistrationViewModelProtocol.swift | 6 +- ...uthenticationRegistrationCoordinator.swift | 8 +- ...uthenticationRegistrationScreenState.swift | 18 ++--- ...enticationRegistrationViewModelTests.swift | 62 +++------------ .../AuthenticationRegistrationScreen.swift | 10 +-- ...thenticationServerSelectionViewModel.swift | 2 +- ...enticationServerSelectionCoordinator.swift | 2 +- .../AuthenticationTermsCoordinator.swift | 2 +- 22 files changed, 147 insertions(+), 197 deletions(-) create mode 100644 RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift delete mode 100644 RiotSwiftUI/Modules/Authentication/Common/HomeserverAddress.swift diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index 355cec9a4..ae9d72f0b 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -281,7 +281,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc let localizedPolicies = terms?.policiesData(forLanguage: Bundle.mxk_language(), defaultLanguage: Bundle.mxk_fallbackLanguage()) let parameters = AuthenticationTermsCoordinatorParameters(registrationWizard: registrationWizard, localizedPolicies: localizedPolicies ?? [], - homeserverAddress: homeserver.addressFromUser ?? homeserver.address) + homeserverAddress: homeserver.displayableAddress) let coordinator = AuthenticationTermsCoordinator(parameters: parameters) coordinator.callback = { [weak self] result in self?.registrationStageDidComplete(with: result) diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift new file mode 100644 index 000000000..bd92001aa --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift @@ -0,0 +1,75 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Information about a homeserver that is ready for display in the authentication flow. +struct AuthenticationHomeserverViewData: Equatable { + /// The homeserver string to be shown to the user. + let address: String + /// Whether or not the homeserver is matrix.org. + let isMatrixDotOrg: Bool + /// Whether or not to display the username and password text fields during login. + let showLoginForm: Bool + /// Whether or not to display the username and password text fields during registration. + let showRegistrationForm: Bool + /// The supported SSO login options. + let ssoIdentityProviders: [SSOIdentityProvider] +} + +// MARK: - Mocks + +extension AuthenticationHomeserverViewData { + /// A mock homeserver that is configured just like matrix.org. + static var mockMatrixDotOrg: AuthenticationHomeserverViewData { + AuthenticationHomeserverViewData(address: "matrix.org", + isMatrixDotOrg: true, + showLoginForm: true, + showRegistrationForm: true, + ssoIdentityProviders: [ + SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil), + SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil), + SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil), + SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil), + SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil) + ]) + } + + /// A mock homeserver that supports login and registration via a password but has no SSO providers. + static var mockBasicServer: AuthenticationHomeserverViewData { + AuthenticationHomeserverViewData(address: "example.com", + isMatrixDotOrg: false, + showLoginForm: true, + showRegistrationForm: true, + ssoIdentityProviders: []) + } + + /// A mock homeserver that supports only supports authentication via a single SSO provider. + static var mockEnterpriseSSO: AuthenticationHomeserverViewData { + AuthenticationHomeserverViewData(address: "company.com", + isMatrixDotOrg: false, + showLoginForm: false, + showRegistrationForm: false, + ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) + } +} + +struct HomeserverAddress { + /// Ensures the address contains a scheme, otherwise makes it `https`. + static func sanitized(_ address: String) -> String { + !address.contains("://") ? "https://\(address.lowercased())" : address.lowercased() + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift index 63f5642f3..2aa8df779 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift @@ -25,7 +25,7 @@ struct AuthenticationServerInfoSection: View { // MARK: - Public let address: String - let description: String? + let showMatrixDotOrgInfo: Bool let editAction: () -> Void // MARK: - Views @@ -42,8 +42,8 @@ struct AuthenticationServerInfoSection: View { .font(theme.fonts.body) .foregroundColor(theme.colors.primaryContent) - if let description = description { - Text(description) + if showMatrixDotOrgInfo { + Text(VectorL10n.authenticationServerInfoMatrixDescription) .font(theme.fonts.caption1) .foregroundColor(theme.colors.tertiaryContent) .accessibilityIdentifier("serverDescriptionText") diff --git a/RiotSwiftUI/Modules/Authentication/Common/HomeserverAddress.swift b/RiotSwiftUI/Modules/Authentication/Common/HomeserverAddress.swift deleted file mode 100644 index d0bca43e0..000000000 --- a/RiotSwiftUI/Modules/Authentication/Common/HomeserverAddress.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -struct HomeserverAddress { - /// Ensures the address contains a scheme, otherwise makes it `https`. - static func sanitized(_ address: String) -> String { - !address.contains("://") ? "https://\(address.lowercased())" : address.lowercased() - } - - /// Strips the `https://` away from the address (but leaves `http://`) for display in labels. - /// - /// `http://` is left in the string to make it clear when a chosen server doesn't use SSL. - static func displayable(_ address: String) -> String { - address.replacingOccurrences(of: "https://", with: "") - } -} diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 4251cab79..0ba85300f 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -188,8 +188,7 @@ class AuthenticationService: NSObject { let state = AuthenticationState(flow: .login, homeserver: .init(address: loginFlow.homeserverAddress, addressFromUser: homeserverAddress, - preferredLoginMode: loginFlow.loginMode, - loginModeSupportedTypes: loginFlow.supportedLoginTypes)) + preferredLoginMode: loginFlow.loginMode)) return (client, state) } @@ -205,8 +204,7 @@ class AuthenticationService: NSObject { let loginFlow = try await getLoginFlowResult(client: session.matrixRestClient) let state = AuthenticationState(flow: .login, homeserver: .init(address: loginFlow.homeserverAddress, - preferredLoginMode: loginFlow.loginMode, - loginModeSupportedTypes: loginFlow.supportedLoginTypes)) + preferredLoginMode: loginFlow.loginMode)) return (client, state) } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift index 1e470e372..637e5abe4 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift @@ -40,11 +40,14 @@ struct AuthenticationState { var address: String /// The homeserver address as input by the user (it can differ to the well-known request). var addressFromUser: String? + /// The homeserver's address formatted to be displayed to the user in labels, text fields etc. + var displayableAddress: String { + let address = addressFromUser ?? address + return address.replacingOccurrences(of: "https://", with: "") // Only remove https. Leave http to indicate the server doesn't use SSL. + } /// The preferred login mode for the server var preferredLoginMode: LoginMode = .unknown - /// Supported types for the login. - var loginModeSupportedTypes = [MXLoginFlow]() /// The response returned when querying the homeserver for registration flows. var registrationFlow: RegistrationResult? @@ -54,5 +57,13 @@ struct AuthenticationState { guard let url = URL(string: address) else { return false } return url.host == "matrix.org" || url.host == "matrix-client.matrix.org" } + + var viewData: AuthenticationHomeserverViewData { + AuthenticationHomeserverViewData(address: displayableAddress, + isMatrixDotOrg: isMatrixDotOrg, + showLoginForm: preferredLoginMode.supportsPasswordFlow, + showRegistrationForm: registrationFlow != nil, + ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? []) + } } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift index c3ca7ea45..e2f3988f5 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -32,24 +32,14 @@ enum AuthenticationLoginViewModelResult { // MARK: View struct AuthenticationLoginViewState: BindableState { - /// The address of the homeserver. - var homeserverAddress: String - /// Whether or not to show the username and password text fields with the next button - var showLoginForm: Bool - /// An array containing the available SSO options for login. - var ssoIdentityProviders: [SSOIdentityProvider] + /// Data about the selected homeserver. + var homeserver: AuthenticationHomeserverViewData /// View state that can be bound to from SwiftUI. var bindings: AuthenticationLoginBindings - /// A description that can be shown for the currently selected homeserver. - var serverDescription: String? { - guard homeserverAddress == "matrix.org" else { return nil } - return VectorL10n.authenticationServerInfoMatrixDescription - } - /// Whether to show any SSO buttons. var showSSOButtons: Bool { - !ssoIdentityProviders.isEmpty + !homeserver.ssoIdentityProviders.isEmpty } /// `true` if it is possible to continue, otherwise `false`. diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift index aab6edbc2..7d0adedd0 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -30,12 +30,9 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica // MARK: - Setup - init(homeserverAddress: String, showLoginForm: Bool = true, ssoIdentityProviders: [SSOIdentityProvider]) { + init(homeserver: AuthenticationHomeserverViewData) { let bindings = AuthenticationLoginBindings() - let viewState = AuthenticationLoginViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress), - showLoginForm: showLoginForm, - ssoIdentityProviders: ssoIdentityProviders, - bindings: bindings) + let viewState = AuthenticationLoginViewState(homeserver: homeserver, bindings: bindings) super.init(initialViewState: viewState) } @@ -57,10 +54,8 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica } } - @MainActor func update(homeserverAddress: String, showLoginForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) { - state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress) - state.showLoginForm = showLoginForm - state.ssoIdentityProviders = ssoIdentityProviders + @MainActor func update(homeserver: AuthenticationHomeserverViewData) { + state.homeserver = homeserver } @MainActor func displayError(_ type: AuthenticationLoginErrorType) { diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift index a9552cf4e..669b9189a 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift @@ -22,11 +22,7 @@ protocol AuthenticationLoginViewModelProtocol { var context: AuthenticationLoginViewModelType.Context { get } /// Update the view with new homeserver information. - /// - Parameters: - /// - homeserverAddress: The homeserver string to be shown to the user. - /// - showLoginForm: Whether or not to display the username and password text fields. - /// - ssoIdentityProviders: The supported SSO login options. - @MainActor func update(homeserverAddress: String, showLoginForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) + @MainActor func update(homeserver: AuthenticationHomeserverViewData) /// Display an error to the user. @MainActor func displayError(_ type: AuthenticationLoginErrorType) diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index b7f989876..8e062b764 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -67,9 +67,7 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { self.parameters = parameters let homeserver = parameters.authenticationService.state.homeserver - let viewModel = AuthenticationLoginViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, - showLoginForm: homeserver.preferredLoginMode.supportsPasswordFlow, - ssoIdentityProviders: parameters.loginMode.ssoIdentityProviders ?? []) + let viewModel = AuthenticationLoginViewModel(homeserver: homeserver.viewData) authenticationLoginViewModel = viewModel let view = AuthenticationLoginScreen(viewModel: viewModel.context) @@ -223,10 +221,9 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { } } + /// Updates the view model to reflect any changes made to the homeserver. @MainActor private func updateViewModel() { let homeserver = authenticationService.state.homeserver - authenticationLoginViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, - showLoginForm: homeserver.preferredLoginMode.supportsPasswordFlow, - ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? []) + authenticationLoginViewModel.update(homeserver: homeserver.viewData) } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift index 6b42c3856..fd0afb87d 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift @@ -38,23 +38,15 @@ enum MockAuthenticationLoginScreenState: MockScreenState, CaseIterable { let viewModel: AuthenticationLoginViewModel switch self { case .matrixDotOrg: - viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://matrix.org", ssoIdentityProviders: [ - SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil), - SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil), - SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil), - SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil), - SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil) - ]) + viewModel = AuthenticationLoginViewModel(homeserver: .mockMatrixDotOrg) case .passwordOnly: - viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel = AuthenticationLoginViewModel(homeserver: .mockBasicServer) case .passwordWithCredentials: - viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel = AuthenticationLoginViewModel(homeserver: .mockBasicServer) viewModel.context.username = "alice" viewModel.context.password = "password" case .ssoOnly: - viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://company.com", - showLoginForm: false, - ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) + viewModel = AuthenticationLoginViewModel(homeserver: .mockEnterpriseSSO) } diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift index 47ef0845d..672e9931b 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -47,11 +47,11 @@ struct AuthenticationLoginScreen: View { .frame(height: 1) .padding(.vertical, 21) - if viewModel.viewState.showLoginForm { + if viewModel.viewState.homeserver.showLoginForm { loginForm } - if viewModel.viewState.showLoginForm && viewModel.viewState.showSSOButtons { + if viewModel.viewState.homeserver.showLoginForm && viewModel.viewState.showSSOButtons { Text(VectorL10n.or) .foregroundColor(theme.colors.secondaryContent) .padding(.top, 16) @@ -82,8 +82,8 @@ struct AuthenticationLoginScreen: View { /// The sever information section that includes a button to select a different server. var serverInfo: some View { - AuthenticationServerInfoSection(address: viewModel.viewState.homeserverAddress, - description: viewModel.viewState.serverDescription) { + AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address, + showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) { viewModel.send(viewAction: .selectServer) } } @@ -129,7 +129,7 @@ struct AuthenticationLoginScreen: View { /// A list of SSO buttons that can be used for login. var ssoButtons: some View { VStack(spacing: 16) { - ForEach(viewModel.viewState.ssoIdentityProviders) { provider in + ForEach(viewModel.viewState.homeserver.ssoIdentityProviders) { provider in AuthenticationSSOButton(provider: provider) { viewModel.send(viewAction: .continueWithSSO(id: provider.id)) } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift index c86f20f32..83228358e 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift @@ -30,12 +30,8 @@ enum AuthenticationRegistrationViewModelResult { // MARK: View struct AuthenticationRegistrationViewState: BindableState { - /// The address of the homeserver. - var homeserverAddress: String - /// Whether or not to show the username and password text fields with the next button - var showRegistrationForm: Bool - /// An array containing the available SSO options for login. - var ssoIdentityProviders: [SSOIdentityProvider] + /// Data about the selected homeserver. + var homeserver: AuthenticationHomeserverViewData /// View state that can be bound to from SwiftUI. var bindings: AuthenticationRegistrationBindings /// Whether or not the username field has been edited yet. @@ -55,15 +51,9 @@ struct AuthenticationRegistrationViewState: BindableState { usernameErrorMessage ?? VectorL10n.authenticationRegistrationUsernameFooter } - /// A description that can be shown for the currently selected homeserver. - var serverDescription: String? { - guard homeserverAddress == "matrix.org" else { return nil } - return VectorL10n.authenticationServerInfoMatrixDescription - } - /// Whether to show any SSO buttons. var showSSOButtons: Bool { - !ssoIdentityProviders.isEmpty + !homeserver.ssoIdentityProviders.isEmpty } /// Whether the current `username` is valid. diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift index 5b12823f3..67dc8f7ee 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift @@ -31,12 +31,9 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy // MARK: - Setup - init(homeserverAddress: String, showRegistrationForm: Bool = true, ssoIdentityProviders: [SSOIdentityProvider]) { + init(homeserver: AuthenticationHomeserverViewData) { let bindings = AuthenticationRegistrationBindings() - let viewState = AuthenticationRegistrationViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress), - showRegistrationForm: showRegistrationForm, - ssoIdentityProviders: ssoIdentityProviders, - bindings: bindings) + let viewState = AuthenticationRegistrationViewState(homeserver: homeserver, bindings: bindings) super.init(initialViewState: viewState) } @@ -60,10 +57,8 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy } } - @MainActor func update(homeserverAddress: String, showRegistrationForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) { - state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress) - state.showRegistrationForm = showRegistrationForm - state.ssoIdentityProviders = ssoIdentityProviders + @MainActor func update(homeserver: AuthenticationHomeserverViewData) { + state.homeserver = homeserver } @MainActor func displayError(_ type: AuthenticationRegistrationErrorType) { diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift index a10a9a4d7..3315fa13c 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift @@ -22,11 +22,7 @@ protocol AuthenticationRegistrationViewModelProtocol { var context: AuthenticationRegistrationViewModelType.Context { get } /// Update the view with new homeserver information. - /// - Parameters: - /// - homeserverAddress: The homeserver string to be shown to the user. - /// - showRegistrationForm: Whether or not to display the username and password text fields. - /// - ssoIdentityProviders: The supported SSO login options. - @MainActor func update(homeserverAddress: String, showRegistrationForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) + @MainActor func update(homeserver: AuthenticationHomeserverViewData) /// Display an error to the user. @MainActor func displayError(_ type: AuthenticationRegistrationErrorType) diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index 4bfa45cd5..3b4aa1c62 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -69,9 +69,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { self.parameters = parameters let homeserver = parameters.authenticationService.state.homeserver - let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, - showRegistrationForm: homeserver.registrationFlow != nil, - ssoIdentityProviders: parameters.loginMode.ssoIdentityProviders ?? []) + let viewModel = AuthenticationRegistrationViewModel(homeserver: homeserver.viewData) authenticationRegistrationViewModel = viewModel let view = AuthenticationRegistrationScreen(viewModel: viewModel.context) @@ -226,9 +224,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) { if result == .updated { let homeserver = authenticationService.state.homeserver - authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, - showRegistrationForm: homeserver.registrationFlow != nil, - ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? []) + authenticationRegistrationViewModel.update(homeserver: homeserver.viewData) } navigationRouter.dismissModule(animated: true) { [weak self] in diff --git a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift index 7f5721fa9..561fda0b8 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift @@ -39,27 +39,19 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable { let viewModel: AuthenticationRegistrationViewModel switch self { case .matrixDotOrg: - viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://matrix.org", ssoIdentityProviders: [ - SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil), - SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil), - SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil), - SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil), - SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil) - ]) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockMatrixDotOrg) case .passwordOnly: - viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer) case .passwordWithCredentials: - viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer) viewModel.context.username = "alice" viewModel.context.password = "password" case .passwordWithUsernameError: - viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer) viewModel.state.hasEditedUsername = true Task { await viewModel.displayError(.usernameUnavailable(VectorL10n.authInvalidUserName)) } case .ssoOnly: - viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://company.com", - showRegistrationForm: false, - ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockEnterpriseSSO) } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift index 0d78b9b1e..9ce6cfbbc 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift @@ -20,78 +20,36 @@ import Combine @testable import RiotSwiftUI @MainActor class AuthenticationRegistrationViewModelTests: XCTestCase { + let defaultHomeserver = AuthenticationHomeserverViewData.mockMatrixDotOrg var viewModel: AuthenticationRegistrationViewModelProtocol! var context: AuthenticationRegistrationViewModelType.Context! @MainActor override func setUp() async throws { - viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "", ssoIdentityProviders: []) + viewModel = AuthenticationRegistrationViewModel(homeserver: defaultHomeserver) context = viewModel.context } func testMatrixDotOrg() { - // Given matrix.org with some SSO providers. - let address = "https://matrix.org" - let ssoProviders = [ - SSOIdentityProvider(id: "apple", name: "Apple", brand: "Apple", iconURL: nil), - SSOIdentityProvider(id: "google", name: "Google", brand: "Google", iconURL: nil), - SSOIdentityProvider(id: "github", name: "Github", brand: "Github", iconURL: nil) - ] + // Given the initial view model configured for matrix.org with some SSO providers. + let homeserver = defaultHomeserver - // When updating the view model with the server. - viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: ssoProviders) - - // Then the form should show the server description along with the username and password fields and the SSO buttons. - XCTAssertEqual(context.viewState.homeserverAddress, "matrix.org", "The homeserver address should have the https scheme stripped away.") - XCTAssertEqual(context.viewState.serverDescription, VectorL10n.authenticationRegistrationMatrixDescription, "A description should be shown for matrix.org.") - XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.") + // Then the view state should contain a homeserver that matches matrix.org and shows SSO buttons. + XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should match the original.") XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.") } func testBasicServer() { // Given a basic server example.com that only supports password registration. - let address = "https://example.com" + let homeserver = AuthenticationHomeserverViewData.mockBasicServer // When updating the view model with the server. - viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: []) + viewModel.update(homeserver: homeserver) - // Then the form should only show the username and password section. - XCTAssertEqual(context.viewState.homeserverAddress, "example.com", "The homeserver address should have the https scheme stripped away.") - XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.") - XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.") + // Then the view state should be updated with the homeserver and hide the SSO buttons. + XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should should match the new homeserver.") XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.") } - func testUnsecureServer() { - // Given a server that uses http for communication. - let address = "http://testserver.local" - - // When updating the view model with the server. - viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: []) - - // Then the form should only show the username and password section. - XCTAssertEqual(context.viewState.homeserverAddress, address, "The homeserver address should show the http scheme.") - XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.") - } - - func testSSOOnlyServer() { - // Given matrix.org with some SSO providers. - let address = "https://example.com" - let ssoProviders = [ - SSOIdentityProvider(id: "apple", name: "Apple", brand: "Apple", iconURL: nil), - SSOIdentityProvider(id: "google", name: "Google", brand: "Google", iconURL: nil), - SSOIdentityProvider(id: "github", name: "Github", brand: "Github", iconURL: nil) - ] - - // When updating the view model with the server. - viewModel.update(homeserverAddress: address, showRegistrationForm: false, ssoIdentityProviders: ssoProviders) - - // Then the form should show the server description along with the username and password fields and the SSO buttons. - XCTAssertEqual(context.viewState.homeserverAddress, "example.com", "The homeserver address should have the https scheme stripped away.") - XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.") - XCTAssertFalse(context.viewState.showRegistrationForm, "The username and password section should not be shown.") - XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.") - } - func testUsernameError() async { // Given a form with a valid username. context.username = "bob" diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift index b0c137ceb..c2106e299 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -45,11 +45,11 @@ struct AuthenticationRegistrationScreen: View { .frame(height: 1) .padding(.vertical, 21) - if viewModel.viewState.showRegistrationForm { + if viewModel.viewState.homeserver.showRegistrationForm { registrationForm } - if viewModel.viewState.showRegistrationForm && viewModel.viewState.showSSOButtons { + if viewModel.viewState.homeserver.showRegistrationForm && viewModel.viewState.showSSOButtons { Text(VectorL10n.or) .foregroundColor(theme.colors.secondaryContent) .padding(.top, 16) @@ -90,8 +90,8 @@ struct AuthenticationRegistrationScreen: View { /// The sever information section that includes a button to select a different server. var serverInfo: some View { - AuthenticationServerInfoSection(address: viewModel.viewState.homeserverAddress, - description: viewModel.viewState.serverDescription) { + AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address, + showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) { viewModel.send(viewAction: .selectServer) } } @@ -135,7 +135,7 @@ struct AuthenticationRegistrationScreen: View { /// A list of SSO buttons that can be used for login. var ssoButtons: some View { VStack(spacing: 16) { - ForEach(viewModel.viewState.ssoIdentityProviders) { provider in + ForEach(viewModel.viewState.homeserver.ssoIdentityProviders) { provider in AuthenticationSSOButton(provider: provider) { viewModel.send(viewAction: .continueWithSSO(id: provider.id)) } diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift index 4484fbcad..ca68ce068 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift @@ -33,7 +33,7 @@ class AuthenticationServerSelectionViewModel: AuthenticationServerSelectionViewM // MARK: - Setup init(homeserverAddress: String, hasModalPresentation: Bool) { - let bindings = AuthenticationServerSelectionBindings(homeserverAddress: HomeserverAddress.displayable(homeserverAddress)) + let bindings = AuthenticationServerSelectionBindings(homeserverAddress: homeserverAddress) super.init(initialViewState: AuthenticationServerSelectionViewState(bindings: bindings, hasModalPresentation: hasModalPresentation)) } diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift index 23a62f658..3ad2b4866 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift @@ -58,7 +58,7 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable { self.parameters = parameters let homeserver = parameters.authenticationService.state.homeserver - let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, + let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.displayableAddress, hasModalPresentation: parameters.hasModalPresentation) let view = AuthenticationServerSelectionScreen(viewModel: viewModel.context) authenticationServerSelectionViewModel = viewModel diff --git a/RiotSwiftUI/Modules/Authentication/Terms/Coordinator/AuthenticationTermsCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Terms/Coordinator/AuthenticationTermsCoordinator.swift index 252406a0e..04a3bac6f 100644 --- a/RiotSwiftUI/Modules/Authentication/Terms/Coordinator/AuthenticationTermsCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Terms/Coordinator/AuthenticationTermsCoordinator.swift @@ -59,7 +59,7 @@ final class AuthenticationTermsCoordinator: Coordinator, Presentable { @MainActor init(parameters: AuthenticationTermsCoordinatorParameters) { self.parameters = parameters - let subtitle = HomeserverAddress.displayable(parameters.homeserverAddress) + let subtitle = parameters.homeserverAddress let policies = parameters.localizedPolicies.compactMap { AuthenticationTermsPolicy(url: $0.url, title: $0.name, subtitle: subtitle) } let viewModel = AuthenticationTermsViewModel(policies: policies) From 292cd4a5d2dfaf346bc90137dac74eb54fb84ab3 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 24 May 2022 14:03:39 +0100 Subject: [PATCH 07/30] Disable the next button whilst loading a server. Add tests for login screen. Self review. --- .../AuthenticationHomeserverViewData.swift | 7 -- .../Common/AuthenticationModels.swift | 7 ++ .../AuthenticationServerInfoSection.swift | 2 + .../MatrixSDK/AuthenticationService.swift | 32 +++--- .../MatrixSDK/AuthenticationState.swift | 1 + .../Login/AuthenticationLoginModels.swift | 2 + .../Login/AuthenticationLoginViewModel.swift | 7 +- ...AuthenticationLoginViewModelProtocol.swift | 5 +- .../AuthenticationLoginCoordinator.swift | 9 +- .../Test/UI/AuthenticationLoginUITests.swift | 90 +++++++++++++++-- .../AuthenticationLoginViewModelTests.swift | 99 +++++++++++++++---- .../View/AuthenticationLoginScreen.swift | 6 +- .../AuthenticationRegistrationViewModel.swift | 2 +- ...icationRegistrationViewModelProtocol.swift | 2 +- ...uthenticationRegistrationCoordinator.swift | 4 +- ...enticationRegistrationViewModelTests.swift | 5 +- ...icationServerSelectionViewModelTests.swift | 5 +- .../Modules/Common/Mock/MockAppScreens.swift | 1 + .../AuthenticationServiceTests.swift | 86 ++++++++++++++++ changelog.d/5654.wip | 1 + 20 files changed, 309 insertions(+), 64 deletions(-) create mode 100644 changelog.d/5654.wip diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift index bd92001aa..484ba1f7b 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift @@ -66,10 +66,3 @@ extension AuthenticationHomeserverViewData { ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) } } - -struct HomeserverAddress { - /// Ensures the address contains a scheme, otherwise makes it `https`. - static func sanitized(_ address: String) -> String { - !address.contains("://") ? "https://\(address.lowercased())" : address.lowercased() - } -} diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift index f9a6dfdd1..282b0c549 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift @@ -73,6 +73,13 @@ enum LoginError: String, Error { case resetPasswordNotStarted } +struct HomeserverAddress { + /// Ensures the address contains a scheme, otherwise makes it `https`. + static func sanitized(_ address: String) -> String { + !address.contains("://") ? "https://\(address.lowercased())" : address.lowercased() + } +} + /// Represents an SSO Identity Provider as provided in a login flow. @objc class SSOIdentityProvider: NSObject, Identifiable { /// The id field is the Identity Provider identifier used for the SSO Web page redirection `/login/sso/redirect/{idp_id}`. diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift index 2aa8df779..7401d2259 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift @@ -16,6 +16,8 @@ import SwiftUI +/// A view that shows information about the chosen homeserver, +/// along with an edit button to pick a different one. struct AuthenticationServerInfoSection: View { // MARK: - Private diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 0ba85300f..fa78a8048 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -84,7 +84,7 @@ class AuthenticationService: NSObject { } func startFlow(_ flow: AuthenticationFlow, for homeserverAddress: String) async throws { - var (client, state) = try await loginFlow(for: homeserverAddress) + var (client, homeserver) = try await loginFlow(for: homeserverAddress) let loginWizard = LoginWizard(client: client) self.loginWizard = loginWizard @@ -92,18 +92,19 @@ class AuthenticationService: NSObject { if flow == .register { do { let registrationWizard = RegistrationWizard(client: client) - state.homeserver.registrationFlow = try await registrationWizard.registrationFlow() - state.flow = .register + homeserver.registrationFlow = try await registrationWizard.registrationFlow() self.registrationWizard = registrationWizard } catch { - guard state.homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else { + guard homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else { throw error } // Continue without throwing when registration is disabled but SSO is available. } } - self.state = state + // The state and client are set after trying the registration flow to + // ensure the existing state isn't wiped out when an error occurs. + self.state = AuthenticationState(flow: flow, homeserver: homeserver) self.client = client } @@ -165,10 +166,11 @@ class AuthenticationService: NSObject { // MARK: - Private - /// Request the supported login flows for this homeserver. + /// Query the supported login flows for the supplied homeserver. /// This is the first method to call to be able to get a wizard to login or to create an account /// - Parameter homeserverAddress: The homeserver string entered by the user. - private func loginFlow(for homeserverAddress: String) async throws -> (AuthenticationRestClient, AuthenticationState) { + /// - Returns: A tuple containing the REST client for the server along with the homeserver state containing the login flows. + private func loginFlow(for homeserverAddress: String) async throws -> (AuthenticationRestClient, AuthenticationState.Homeserver) { let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress) guard var homeserverURL = URL(string: homeserverAddress) else { @@ -186,16 +188,16 @@ class AuthenticationService: NSObject { let loginFlow = try await getLoginFlowResult(client: client) - let state = AuthenticationState(flow: .login, homeserver: .init(address: loginFlow.homeserverAddress, - addressFromUser: homeserverAddress, - preferredLoginMode: loginFlow.loginMode)) - return (client, state) + let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress, + addressFromUser: homeserverAddress, + preferredLoginMode: loginFlow.loginMode) + return (client, homeserver) } /// Request the supported login flows for the corresponding session. /// This method is used to get the flows for a server after a soft-logout. /// - Parameter session: The MXSession where a soft-logout has occurred. - private func loginFlow(for session: MXSession) async throws -> (AuthenticationRestClient, AuthenticationState) { + private func loginFlow(for session: MXSession) async throws -> (AuthenticationRestClient, AuthenticationState.Homeserver) { guard let client = session.matrixRestClient else { MXLog.error("[AuthenticationService] loginFlow called on a session that doesn't have a matrixRestClient.") throw AuthenticationError.missingMXRestClient @@ -203,9 +205,9 @@ class AuthenticationService: NSObject { let loginFlow = try await getLoginFlowResult(client: session.matrixRestClient) - let state = AuthenticationState(flow: .login, homeserver: .init(address: loginFlow.homeserverAddress, - preferredLoginMode: loginFlow.loginMode)) - return (client, state) + let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress, + preferredLoginMode: loginFlow.loginMode) + return (client, homeserver) } private func getLoginFlowResult(client: MXRestClient) async throws -> LoginFlowResult { diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift index 637e5abe4..dbfcaf26d 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift @@ -58,6 +58,7 @@ struct AuthenticationState { return url.host == "matrix.org" || url.host == "matrix-client.matrix.org" } + /// The homeserver mapped into view data that is ready for display. var viewData: AuthenticationHomeserverViewData { AuthenticationHomeserverViewData(address: displayableAddress, isMatrixDotOrg: isMatrixDotOrg, diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift index e2f3988f5..321fa81e6 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -34,6 +34,8 @@ enum AuthenticationLoginViewModelResult { struct AuthenticationLoginViewState: BindableState { /// Data about the selected homeserver. var homeserver: AuthenticationHomeserverViewData + /// Whether a new homeserver is currently being loaded. + var isLoading: Bool = false /// View state that can be bound to from SwiftUI. var bindings: AuthenticationLoginBindings diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift index 7d0adedd0..270c713a6 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -26,7 +26,7 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica // MARK: Public - @MainActor var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? + var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? // MARK: - Setup @@ -54,6 +54,11 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica } } + @MainActor func update(isLoading: Bool) { + guard state.isLoading != isLoading else { return } + state.isLoading = isLoading + } + @MainActor func update(homeserver: AuthenticationHomeserverViewData) { state.homeserver = homeserver } diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift index 669b9189a..e29367940 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift @@ -18,9 +18,12 @@ import Foundation protocol AuthenticationLoginViewModelProtocol { - @MainActor var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? { get set } + var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? { get set } var context: AuthenticationLoginViewModelType.Context { get } + /// Update the view to reflect that a new homeserver is being loaded. + @MainActor func update(isLoading: Bool) + /// Update the view with new homeserver information. @MainActor func update(homeserver: AuthenticationHomeserverViewData) diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index 8e062b764..4293c3542 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -59,7 +59,7 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { // Must be used only internally var childCoordinators: [Coordinator] = [] - @MainActor var callback: ((AuthenticationLoginCoordinatorResult) -> Void)? + var callback: (@MainActor (AuthenticationLoginCoordinatorResult) -> Void)? // MARK: - Setup @@ -111,10 +111,15 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { /// Show a blocking activity indicator whilst saving. @MainActor private func startLoading(isInteractionBlocking: Bool) { waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: isInteractionBlocking)) + + if !isInteractionBlocking { + authenticationLoginViewModel.update(isLoading: true) + } } /// Hide the currently displayed activity indicator. @MainActor private func stopLoading() { + authenticationLoginViewModel.update(isLoading: false) waitingIndicator = nil } @@ -190,7 +195,7 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { /// Presents the server selection screen as a modal. @MainActor private func presentServerSelectionScreen() { - MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") + MXLog.debug("[AuthenticationLoginCoordinator] presentServerSelectionScreen") let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, flow: .login, hasModalPresentation: true) diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift index 243f40cce..f5a00f526 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift @@ -26,19 +26,93 @@ class AuthenticationLoginUITests: MockScreenTest { override class func createTest() -> MockScreenTest { return AuthenticationLoginUITests(selector: #selector(verifyAuthenticationLoginScreen)) } - + func verifyAuthenticationLoginScreen() throws { guard let screenState = screenState as? MockAuthenticationLoginScreenState else { fatalError("no screen") } switch screenState { - case .promptType(let promptType): - verifyAuthenticationLoginPromptType(promptType: promptType) + case .matrixDotOrg: + let state = "matrix.org" + validateServerDescriptionIsVisible(for: state) + validateLoginFormIsVisible(for: state) + validateSSOButtonsAreShown(for: state) + case .passwordOnly: + let state = "a password only server" + validateServerDescriptionIsHidden(for: state) + validateLoginFormIsVisible(for: state) + validateSSOButtonsAreHidden(for: state) + + validateNextButtonIsDisabled(for: state) + case .passwordWithCredentials: + let state = "a password only server with credentials entered" + validateNextButtonIsEnabled(for: state) + case .ssoOnly: + let state = "an SSO only server" + validateServerDescriptionIsHidden(for: state) + validateLoginFormIsHidden(for: state) + validateSSOButtonsAreShown(for: state) } } - func verifyAuthenticationLoginPromptType(promptType: AuthenticationLoginPromptType) { - let title = app.staticTexts["title"] - XCTAssert(title.exists) - XCTAssertEqual(title.label, promptType.title) + /// Checks that the server description label is shown. + func validateServerDescriptionIsVisible(for state: String) { + let descriptionLabel = app.staticTexts["serverDescriptionText"] + + XCTAssertTrue(descriptionLabel.exists, "The server description should be shown for \(state).") + XCTAssertEqual(descriptionLabel.label, VectorL10n.authenticationServerInfoMatrixDescription, "The server description should be correct for \(state).") } - + + /// Checks that the server description label is hidden. + func validateServerDescriptionIsHidden(for state: String) { + let descriptionLabel = app.staticTexts["serverDescriptionText"] + XCTAssertFalse(descriptionLabel.exists, "The server description should be shown for \(state).") + } + + /// Checks that the username and password text fields are shown along with the next button. + func validateLoginFormIsVisible(for state: String) { + let usernameTextField = app.textFields.element + let passwordTextField = app.secureTextFields.element + let nextButton = app.buttons["nextButton"] + + XCTAssertTrue(usernameTextField.exists, "Username input should be shown for \(state).") + XCTAssertTrue(passwordTextField.exists, "Password input should be shown for \(state).") + XCTAssertTrue(nextButton.exists, "The next button should be shown for \(state).") + } + + /// Checks that the username and password text fields are hidden along with the next button. + func validateLoginFormIsHidden(for state: String) { + let usernameTextField = app.textFields.element + let passwordTextField = app.secureTextFields.element + let nextButton = app.buttons["nextButton"] + + XCTAssertFalse(usernameTextField.exists, "Username input should not be shown for \(state).") + XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).") + XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).") + } + + /// Checks that there is at least one SSO button shown on the screen. + func validateSSOButtonsAreShown(for state: String) { + let ssoButtons = app.buttons.matching(identifier: "ssoButton") + XCTAssertGreaterThan(ssoButtons.count, 0, "There should be at least 1 SSO button shown for \(state).") + } + + /// Checks that no SSO buttons shown on the screen. + func validateSSOButtonsAreHidden(for state: String) { + let ssoButtons = app.buttons.matching(identifier: "ssoButton") + XCTAssertEqual(ssoButtons.count, 0, "There should not be any SSO buttons shown for \(state).") + } + + /// Checks that the next button is shown but is disabled. + func validateNextButtonIsDisabled(for state: String) { + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should be shown.") + XCTAssertFalse(nextButton.isEnabled, "The next button should be disabled for \(state).") + } + + /// Checks that the next button is shown and is enabled. + func validateNextButtonIsEnabled(for state: String) { + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should be shown.") + XCTAssertTrue(nextButton.isEnabled, "The next button should be enabled for \(state).") + } + } diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift index 2635c4325..671ccb733 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift @@ -19,30 +19,95 @@ import XCTest @testable import RiotSwiftUI class AuthenticationLoginViewModelTests: XCTestCase { - private enum Constants { - static let counterInitialValue = 0 - } - + let defaultHomeserver = AuthenticationHomeserverViewData.mockMatrixDotOrg var viewModel: AuthenticationLoginViewModelProtocol! var context: AuthenticationLoginViewModelType.Context! - override func setUpWithError() throws { - viewModel = AuthenticationLoginViewModel(promptType: .regular, initialCount: Constants.counterInitialValue) + @MainActor override func setUp() async throws { + viewModel = AuthenticationLoginViewModel(homeserver: defaultHomeserver) context = viewModel.context } - - func testInitialState() { - XCTAssertEqual(context.viewState.count, Constants.counterInitialValue) + + func testMatrixDotOrg() { + // Given the initial view model configured for matrix.org with some SSO providers. + let homeserver = defaultHomeserver + + // Then the view state should contain a homeserver that matches matrix.org and shows SSO buttons. + XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should match the original.") + XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.") } - - func testCounter() throws { - context.send(viewAction: .incrementCount) - XCTAssertEqual(context.viewState.count, 1) + + @MainActor func testBasicServer() { + // Given a basic server example.com that only supports password registration. + let homeserver = AuthenticationHomeserverViewData.mockBasicServer - context.send(viewAction: .incrementCount) - XCTAssertEqual(context.viewState.count, 2) + // When updating the view model with the server. + viewModel.update(homeserver: homeserver) - context.send(viewAction: .decrementCount) - XCTAssertEqual(context.viewState.count, 1) + // Then the view state should be updated with the homeserver and hide the SSO buttons. + XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should should match the new homeserver.") + XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not 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.") + + // 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.") + } + + 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.") + + // 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.") + } + + 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.") + + // 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.") + } + + @MainActor 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.") + + // 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.") + + // 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.") } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift index 672e9931b..cd08e9c56 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -72,7 +72,7 @@ struct AuthenticationLoginScreen: View { .accentColor(theme.colors.accent) } - /// The header containing the icon, title and message. + /// The header containing a Welcome Back title. var header: some View { Text(VectorL10n.authenticationLoginTitle) .font(theme.fonts.title2B) @@ -110,7 +110,7 @@ struct AuthenticationLoginScreen: View { onEditingChanged: passwordEditingChanged) .accessibilityIdentifier("passwordTextField") - Button { } label: { + Button { viewModel.send(viewAction: .forgotPassword) } label: { Text(VectorL10n.authenticationLoginForgotPassword) .font(theme.fonts.body) } @@ -121,7 +121,7 @@ struct AuthenticationLoginScreen: View { Text(VectorL10n.next) } .buttonStyle(PrimaryActionButtonStyle()) - .disabled(!viewModel.viewState.hasValidCredentials) + .disabled(!viewModel.viewState.hasValidCredentials || viewModel.viewState.isLoading) .accessibilityIdentifier("nextButton") } } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift index 67dc8f7ee..6987abda9 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift @@ -27,7 +27,7 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy // MARK: Public - @MainActor var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? + var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? // MARK: - Setup diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift index 3315fa13c..af786c78f 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift @@ -18,7 +18,7 @@ import Foundation protocol AuthenticationRegistrationViewModelProtocol { - @MainActor var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? { get set } + var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? { get set } var context: AuthenticationRegistrationViewModelType.Context { get } /// Update the view with new homeserver information. diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index 3b4aa1c62..86e784ff7 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -61,7 +61,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { // Must be used only internally var childCoordinators: [Coordinator] = [] - @MainActor var callback: ((AuthenticationRegistrationCoordinatorResult) -> Void)? + var callback: (@MainActor (AuthenticationRegistrationCoordinatorResult) -> Void)? // MARK: - Setup @@ -200,7 +200,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { /// Presents the server selection screen as a modal. @MainActor private func presentServerSelectionScreen() { - MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") + MXLog.debug("[AuthenticationRegistrationCoordinator] presentServerSelectionScreen") let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, flow: .register, hasModalPresentation: true) diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift index 9ce6cfbbc..2724495b6 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift @@ -50,7 +50,7 @@ import Combine XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.") } - func testUsernameError() async { + func testUsernameError() async throws { // Given a form with a valid username. context.username = "bob" XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error when the view model is created.") @@ -71,8 +71,7 @@ import Combine context.send(viewAction: .clearUsernameError) // Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors. - let task = Task { try await Task.sleep(nanoseconds: 100_000_000) } - _ = await task.result + try await Task.sleep(nanoseconds: 100_000_000) // Then the error should be hidden again. XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error anymore.") diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift index 46f4aac08..bdab021d3 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift @@ -31,7 +31,7 @@ class AuthenticationServerSelectionViewModelTests: XCTestCase { context = viewModel.context } - @MainActor func testErrorMessage() async { + @MainActor func testErrorMessage() async throws { // Given a new instance of the view model. XCTAssertNil(context.viewState.footerErrorMessage, "There should not be an error message for a new view model.") XCTAssertEqual(context.viewState.footerMessage, VectorL10n.authenticationServerSelectionServerFooter, "The standard footer message should be shown.") @@ -48,8 +48,7 @@ class AuthenticationServerSelectionViewModelTests: XCTestCase { context.send(viewAction: .clearFooterError) // Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors. - let task = Task { try await Task.sleep(nanoseconds: 100_000_000) } - _ = await task.result + try await Task.sleep(nanoseconds: 100_000_000) // Then the error message should now be removed. XCTAssertNil(context.viewState.footerErrorMessage, "The error message should have been cleared.") diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 9f35fe53f..f357c67ae 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -21,6 +21,7 @@ import Foundation enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ MockLiveLocationSharingViewerScreenState.self, + MockAuthenticationLoginScreenState.self, MockAuthenticationReCaptchaScreenState.self, MockAuthenticationTermsScreenState.self, MockAuthenticationVerifyEmailScreenState.self, diff --git a/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift b/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift index 11304eb23..6dfe7dfbc 100644 --- a/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift +++ b/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift @@ -81,4 +81,90 @@ class AuthenticationServiceTests: XCTestCase { XCTAssertEqual(service.state.homeserver.addressFromUser, "https://matrix.org", "The new address entered by the user should be stored.") XCTAssertEqual(service.state.homeserver.address, "https://matrix-client.matrix.org", "The new address discovered from the well-known should be stored.") } + + func testHomeserverViewDataForMatrixDotOrg() { + // Given a homeserver such as matrix.org. + let address = "https://matrix-client.matrix.org" + let addressFromUser = "https://matrix.org" // https is added when sanitising the input. + let ssoIdentityProviders = [ + SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil), + SSOIdentityProvider(id: "2", name: "GitHub", brand: "github", iconURL: nil) + ] + let flowResult = FlowResult(missingStages: [.email(isMandatory: true), .reCaptcha(isMandatory: true, siteKey: "1234")], completedStages: []) + let homeserver = AuthenticationState.Homeserver(address: address, + addressFromUser: addressFromUser, + preferredLoginMode: .ssoAndPassword(ssoIdentityProviders: ssoIdentityProviders), + registrationFlow: .flowResponse(flowResult)) + + // When creating view data for that homeserver. + let viewData = homeserver.viewData + + // Then the view data should correctly represent the homeserver. + XCTAssertEqual(viewData.address, "matrix.org", "The displayed address should match the address supplied by the user, but without the scheme.") + XCTAssertEqual(viewData.isMatrixDotOrg, true, "The server should be detected as matrix.org.") + XCTAssertTrue(viewData.showLoginForm, "The login form should be shown.") + XCTAssertEqual(viewData.ssoIdentityProviders, ssoIdentityProviders, "The sso identity providers should match.") + XCTAssertTrue(viewData.showRegistrationForm, "The registration form should be shown.") + } + + func testHomeserverViewDataForPasswordLoginOnly() { + // Given a homeserver with password login and registration disabled. + let address = "https://matrix.example.com" + let addressFromUser = "https://example.com" // https is added when sanitising the input. + let homeserver = AuthenticationState.Homeserver(address: address, + addressFromUser: addressFromUser, + preferredLoginMode: .password, + registrationFlow: nil) + + // When creating view data for that homeserver. + let viewData = homeserver.viewData + + // Then the view data should correctly represent the homeserver. + XCTAssertEqual(viewData.address, "example.com", "The displayed address should match the address supplied by the user, but without the scheme.") + XCTAssertEqual(viewData.isMatrixDotOrg, false, "The server should not be detected as matrix.org.") + XCTAssertTrue(viewData.showLoginForm, "The login form should be shown.") + XCTAssertEqual(viewData.ssoIdentityProviders, [], "There shouldn't be any sso identity providers.") + XCTAssertFalse(viewData.showRegistrationForm, "The registration form should not be shown.") + } + + func testHomeserverViewDataForSSOOnly() { + // Given a homeserver that only supports authentication via SSO. + let address = "https://matrix.company.com" + let addressFromUser = "https://company.com" // https is added when sanitising the input. + let ssoIdentityProviders = [SSOIdentityProvider(id: "1", name: "SAML", brand: nil, iconURL: nil)] + let homeserver = AuthenticationState.Homeserver(address: address, + addressFromUser: addressFromUser, + preferredLoginMode: .sso(ssoIdentityProviders: ssoIdentityProviders), + registrationFlow: nil) + + // When creating view data for that homeserver. + let viewData = homeserver.viewData + + // Then the view data should correctly represent the homeserver. + XCTAssertEqual(viewData.address, "company.com", "The displayed address should match the address supplied by the user, but without the scheme.") + XCTAssertEqual(viewData.isMatrixDotOrg, false, "The server should not be detected as matrix.org.") + XCTAssertFalse(viewData.showLoginForm, "The login form should not be shown.") + XCTAssertEqual(viewData.ssoIdentityProviders, ssoIdentityProviders, "The sso identity providers should match.") + XCTAssertFalse(viewData.showRegistrationForm, "The registration form should not be shown.") + } + + func testHomeserverViewDataForLocalHomeserver() { + // Given a local homeserver that supports login and registration but only via a password. + let addressFromUser = "http://localhost:8008" // https is added when sanitising the input. + let flowResult = FlowResult(missingStages: [.dummy(isMandatory: true)], completedStages: []) + let homeserver = AuthenticationState.Homeserver(address: addressFromUser, + addressFromUser: addressFromUser, + preferredLoginMode: .password, + registrationFlow: .flowResponse(flowResult)) + + // When creating view data for that homeserver. + let viewData = homeserver.viewData + + // Then the view data should correctly represent the homeserver. + XCTAssertEqual(viewData.address, "http://localhost:8008", "The displayed address should match address supplied by the user, complete with the scheme.") + XCTAssertEqual(viewData.isMatrixDotOrg, false, "The server should not be detected as matrix.org.") + XCTAssertTrue(viewData.showLoginForm, "The login form should be shown.") + XCTAssertEqual(viewData.ssoIdentityProviders, [], "There shouldn't be any sso identity providers.") + XCTAssertTrue(viewData.showRegistrationForm, "The registration form should be shown.") + } } diff --git a/changelog.d/5654.wip b/changelog.d/5654.wip new file mode 100644 index 000000000..6f669d34a --- /dev/null +++ b/changelog.d/5654.wip @@ -0,0 +1 @@ +Authentication: Add the login screen to the new flow. From 5b66e023feb3baf61afe5bdeeee3756aa09c5f0b Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 25 May 2022 09:48:52 +0100 Subject: [PATCH 08/30] Add parameters to doc comments. --- .../Login/AuthenticationLoginViewModelProtocol.swift | 3 +++ .../AuthenticationRegistrationViewModelProtocol.swift | 2 ++ 2 files changed, 5 insertions(+) diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift index e29367940..afe9d3a92 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift @@ -22,11 +22,14 @@ protocol AuthenticationLoginViewModelProtocol { var context: AuthenticationLoginViewModelType.Context { get } /// Update the view to reflect that a new homeserver is being loaded. + /// - Parameter isLoading: Whether or not the homeserver is being loaded. @MainActor 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`. @MainActor func update(homeserver: AuthenticationHomeserverViewData) /// Display an error to the user. + /// - Parameter type: The type of error to be displayed. @MainActor func displayError(_ type: AuthenticationLoginErrorType) } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift index af786c78f..e292edf8e 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift @@ -22,8 +22,10 @@ protocol AuthenticationRegistrationViewModelProtocol { var context: AuthenticationRegistrationViewModelType.Context { get } /// Update the view with new homeserver information. + /// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`. @MainActor func update(homeserver: AuthenticationHomeserverViewData) /// Display an error to the user. + /// - Parameter type: The type of error to be displayed. @MainActor func displayError(_ type: AuthenticationRegistrationErrorType) } From 20dc2f33afb51a565a57f6c57d0e603cab3f0249 Mon Sep 17 00:00:00 2001 From: MaximeE Date: Wed, 25 May 2022 11:12:31 +0200 Subject: [PATCH 09/30] 6202: Changelog --- changelog.d/6202.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/6202.bugfix diff --git a/changelog.d/6202.bugfix b/changelog.d/6202.bugfix new file mode 100644 index 000000000..f88ba6a7a --- /dev/null +++ b/changelog.d/6202.bugfix @@ -0,0 +1 @@ +Location sharing: Improve automatic detection of pin drop state From 130106f6ad7084846755200697228df9ec9a40dc Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 25 May 2022 14:48:18 +0100 Subject: [PATCH 10/30] Add SSOAuthenticationPresenter to the AuthenticationCoordinator. --- .../AuthenticationCoordinator.swift | 70 +++++++++++++++++-- .../Login/AuthenticationLoginModels.swift | 6 +- .../Login/AuthenticationLoginViewModel.swift | 4 +- .../AuthenticationLoginCoordinator.swift | 5 ++ .../View/AuthenticationLoginScreen.swift | 2 +- .../AuthenticationRegistrationModels.swift | 6 +- .../AuthenticationRegistrationViewModel.swift | 4 +- ...uthenticationRegistrationCoordinator.swift | 5 ++ .../AuthenticationRegistrationScreen.swift | 2 +- 9 files changed, 88 insertions(+), 16 deletions(-) diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index ae9d72f0b..d57b1de80 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -42,10 +42,17 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc private let navigationRouter: NavigationRouterType private let authenticationService = AuthenticationService.shared + /// The initial screen to be shown when starting the coordinator. private let initialScreen: EntryPoint + /// The presenter used to handler authentication via SSO. + private var ssoAuthenticationPresenter: SSOAuthenticationPresenter? + + /// Whether the coordinator can present further screens after a successful login has occurred. private var canPresentAdditionalScreens: Bool + /// `true` if presentation of the verification screen is blocked by `canPresentAdditionalScreens`. private var isWaitingToPresentCompleteSecurity = false + /// The listener object that informs the coordinator whether verification needs to be presented or not. private var verificationListener: SessionVerificationListener? /// The password entered, for use when setting up cross-signing. @@ -157,7 +164,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc let coordinator = AuthenticationLoginCoordinator(parameters: parameters) coordinator.callback = { [weak self, weak coordinator] result in guard let self = self, let coordinator = coordinator else { return } - self.loginCoordinator(coordinator, didCompleteWith: result) + self.loginCoordinator(coordinator, didCallbackWith: result) } coordinator.start() @@ -172,10 +179,12 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } } - /// Displays the next view in the flow after the registration screen. + /// Displays the next view in the flow based on the result from the registration screen. @MainActor private func loginCoordinator(_ coordinator: AuthenticationLoginCoordinator, - didCompleteWith result: AuthenticationLoginCoordinatorResult) { + didCallbackWith result: AuthenticationLoginCoordinatorResult) { switch result { + case .continueWithSSO(let provider): + presentSSOAuthentication(for: provider) case .success(let session): onSessionCreated(session: session, flow: .login) } @@ -231,7 +240,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc let coordinator = AuthenticationRegistrationCoordinator(parameters: parameters) coordinator.callback = { [weak self, weak coordinator] result in guard let self = self, let coordinator = coordinator else { return } - self.registrationCoordinator(coordinator, didCompleteWith: result) + self.registrationCoordinator(coordinator, didCallbackWith: result) } coordinator.start() @@ -246,10 +255,12 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } } - /// Displays the next view in the flow after the registration screen. + /// Displays the next view in the flow based on the result from the registration screen. @MainActor private func registrationCoordinator(_ coordinator: AuthenticationRegistrationCoordinator, - didCompleteWith result: AuthenticationRegistrationCoordinatorResult) { + didCallbackWith result: AuthenticationRegistrationCoordinatorResult) { switch result { + case .continueWithSSO(let provider): + presentSSOAuthentication(for: provider) case .completed(let result): handleRegistrationResult(result) } @@ -454,6 +465,53 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } } +// MARK: - SSO + +extension AuthenticationCoordinator: SSOAuthenticationPresenterDelegate { + /// Presents SSO authentication for the specified identity provider. + private func presentSSOAuthentication(for identityProvider: SSOIdentityProvider) { + let service = SSOAuthenticationService(homeserverStringURL: authenticationService.state.homeserver.address) + let presenter = SSOAuthenticationPresenter(ssoAuthenticationService: service) + presenter.delegate = self + + presenter.present(forIdentityProvider: identityProvider, + with: MXTools.generateTransactionId(), + from: toPresentable(), + animated: true) + + self.ssoAuthenticationPresenter = presenter + } + + func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationSucceededWithToken token: String, usingIdentityProvider identityProvider: SSOIdentityProvider?) { + MXLog.debug("[AuthenticationCoordinator] SSO authentication succeeded.") + + guard let loginWizard = authenticationService.loginWizard else { + MXLog.failure("[AuthenticationCoordinator] The login wizard was requested before getting the login flow.") + return + } + + Task { + let session = try await loginWizard.login(with: token) + await onSessionCreated(session: session, flow: authenticationService.state.flow) + self.ssoAuthenticationPresenter = nil + } + } + + func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationDidFailWithError error: Error) { + MXLog.debug("[AuthenticationCoordinator] SSO authentication failed.") + + Task { + await displayError(message: error.localizedDescription) + self.ssoAuthenticationPresenter = nil + } + } + + func ssoAuthenticationPresenterDidCancel(_ presenter: SSOAuthenticationPresenter) { + MXLog.debug("[AuthenticationCoordinator] SSO authentication cancelled.") + self.ssoAuthenticationPresenter = nil + } +} + // MARK: - KeyVerificationCoordinatorDelegate extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift index 321fa81e6..8d6b564a4 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -27,6 +27,8 @@ enum AuthenticationLoginViewModelResult { case forgotPassword /// Login using the supplied credentials. case login(username: String, password: String) + /// Continue using the supplied SSO provider. + case continueWithSSO(SSOIdentityProvider) } // MARK: View @@ -68,8 +70,8 @@ enum AuthenticationLoginViewAction { case forgotPassword /// Continue using the input username and password. case next - /// Login using the supplied SSO provider ID. - case continueWithSSO(id: String) + /// Continue using the supplied SSO provider. + case continueWithSSO(SSOIdentityProvider) } enum AuthenticationLoginErrorType: Hashable { diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift index 270c713a6..d3c73495f 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -49,8 +49,8 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica Task { await callback?(.forgotPassword) } case .next: Task { await callback?(.login(username: state.bindings.username, password: state.bindings.password)) } - case .continueWithSSO(let id): - break + case .continueWithSSO(let provider): + Task { await callback?(.continueWithSSO(provider))} } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index 4293c3542..3e465f124 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -26,6 +26,8 @@ struct AuthenticationLoginCoordinatorParameters { } enum AuthenticationLoginCoordinatorResult { + /// Continue using the supplied SSO provider. + case continueWithSSO(SSOIdentityProvider) /// Login was successful with the associated session created. case success(MXSession) } @@ -95,6 +97,7 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { authenticationLoginViewModel.callback = { [weak self] result in guard let self = self else { return } MXLog.debug("[AuthenticationLoginCoordinator] AuthenticationLoginViewModel did callback with result: \(result).") + switch result { case .selectServer: self.presentServerSelectionScreen() @@ -104,6 +107,8 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { #warning("Show the forgot password flow.") case .login(let username, let password): self.login(username: username, password: password) + case .continueWithSSO(let identityProvider): + self.callback?(.continueWithSSO(identityProvider)) } } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift index cd08e9c56..a2b9aaf11 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -131,7 +131,7 @@ struct AuthenticationLoginScreen: View { VStack(spacing: 16) { ForEach(viewModel.viewState.homeserver.ssoIdentityProviders) { provider in AuthenticationSSOButton(provider: provider) { - viewModel.send(viewAction: .continueWithSSO(id: provider.id)) + viewModel.send(viewAction: .continueWithSSO(provider)) } .accessibilityIdentifier("ssoButton") } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift index 83228358e..1f184819d 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift @@ -25,6 +25,8 @@ enum AuthenticationRegistrationViewModelResult { case validateUsername(String) /// Create an account using the supplied credentials. case createAccount(username: String, password: String) + /// Continue using the supplied SSO provider. + case continueWithSSO(SSOIdentityProvider) } // MARK: View @@ -92,8 +94,8 @@ enum AuthenticationRegistrationViewAction { case clearUsernameError /// Continue using the input username and password. case next - /// Login using the supplied SSO provider ID. - case continueWithSSO(id: String) + /// Continue using the supplied SSO provider. + case continueWithSSO(SSOIdentityProvider) } enum AuthenticationRegistrationErrorType: Hashable { diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift index 6987abda9..d88d9b470 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift @@ -52,8 +52,8 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy Task { await clearUsernameError() } case .next: Task { await callback?(.createAccount(username: state.bindings.username, password: state.bindings.password)) } - case .continueWithSSO(let id): - break + case .continueWithSSO(let provider): + Task { await callback?(.continueWithSSO(provider)) } } } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index 86e784ff7..da3f641cd 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -28,6 +28,8 @@ struct AuthenticationRegistrationCoordinatorParameters { } enum AuthenticationRegistrationCoordinatorResult { + /// Continue using the supplied SSO provider. + case continueWithSSO(SSOIdentityProvider) /// The screen completed with the associated registration result. case completed(RegistrationResult) } @@ -97,6 +99,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { authenticationRegistrationViewModel.callback = { [weak self] result in guard let self = self else { return } MXLog.debug("[AuthenticationRegistrationCoordinator] AuthenticationRegistrationViewModel did complete with result: \(result).") + switch result { case .selectServer: self.presentServerSelectionScreen() @@ -104,6 +107,8 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { self.validateUsername(username) case .createAccount(let username, let password): self.createAccount(username: username, password: password) + case .continueWithSSO(let provider): + self.callback?(.continueWithSSO(provider)) } } } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift index c2106e299..60bff52a4 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -137,7 +137,7 @@ struct AuthenticationRegistrationScreen: View { VStack(spacing: 16) { ForEach(viewModel.viewState.homeserver.ssoIdentityProviders) { provider in AuthenticationSSOButton(provider: provider) { - viewModel.send(viewAction: .continueWithSSO(id: provider.id)) + viewModel.send(viewAction: .continueWithSSO(provider)) } .accessibilityIdentifier("ssoButton") } From 5eb676be30a1b55bf6da65f1126876923c621828 Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 25 May 2022 16:36:50 +0100 Subject: [PATCH 11/30] Handle SSO deep links via the AuthenticationServiceDelegate. --- Riot/Modules/Application/AppCoordinator.swift | 4 +- Riot/Modules/Application/LegacyAppDelegate.h | 8 --- Riot/Modules/Application/LegacyAppDelegate.m | 15 ---- .../AuthenticationCoordinatorProtocol.swift | 3 - .../AuthenticationViewController.m | 6 ++ .../LegacyAuthenticationCoordinator.swift | 15 ++-- .../SSO/SSOAuthenticationPresenter.swift | 13 ++-- .../AuthenticationCoordinator.swift | 71 +++++++++++++------ .../Onboarding/OnboardingCoordinator.swift | 6 -- ...OnboardingCoordinatorBridgePresenter.swift | 6 -- .../OnboardingCoordinatorProtocol.swift | 3 - .../MatrixSDK/AuthenticationService.swift | 27 ++++--- changelog.d/5654.wip | 2 +- 13 files changed, 92 insertions(+), 87 deletions(-) diff --git a/Riot/Modules/Application/AppCoordinator.swift b/Riot/Modules/Application/AppCoordinator.swift index af141ad72..cd9591d0b 100755 --- a/Riot/Modules/Application/AppCoordinator.swift +++ b/Riot/Modules/Application/AppCoordinator.swift @@ -189,8 +189,8 @@ final class AppCoordinator: NSObject, AppCoordinatorType { let canOpenLink: Bool switch deepLinkOption { - case .connect(let loginToken, let transactionId): - canOpenLink = self.legacyAppDelegate.continueSSOLogin(withToken: loginToken, txnId: transactionId) + case .connect(let loginToken, let transactionID): + canOpenLink = AuthenticationService.shared.continueSSOLogin(with: loginToken, and: transactionID) } return canOpenLink diff --git a/Riot/Modules/Application/LegacyAppDelegate.h b/Riot/Modules/Application/LegacyAppDelegate.h index 6875c7d0d..459776cf3 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.h +++ b/Riot/Modules/Application/LegacyAppDelegate.h @@ -282,14 +282,6 @@ UINavigationControllerDelegate */ - (void)checkAppVersion; -#pragma mark - Authentication - -/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters. -/// @param loginToken The login token provided when SSO succeeded. -/// @param txnId transaction id generated during SSO page presentation. -/// returns YES if the SSO login can be continued. -- (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId; - @end @protocol LegacyAppDelegateDelegate diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index d51df3891..d92662bd9 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -4696,21 +4696,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [self presentViewController:viewController animated:YES completion:completion]; } -#pragma mark - Authentication - -- (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId -{ - OnboardingCoordinatorBridgePresenter *bridgePresenter = self.masterTabBarController.onboardingCoordinatorBridgePresenter; - - if (!bridgePresenter) - { - MXLogDebug(@"[AppDelegate] Fail to continue SSO login"); - return NO; - } - - return [bridgePresenter continueSSOLoginWithToken:loginToken transactionID:txnId]; -} - #pragma mark - Private - (void)clearCache diff --git a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift index 13a33b2d0..4265c3162 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift @@ -50,9 +50,6 @@ protocol AuthenticationCoordinatorProtocol: Coordinator, Presentable { /// Set up the authentication screen with the specified homeserver and/or identity server. func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?) - /// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters. - func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool - /// Indicates to the coordinator to display any pending screens if it was created with /// the `canPresentAdditionalScreens` parameter set to `false` func presentPendingScreensIfNecessary() diff --git a/Riot/Modules/Authentication/AuthenticationViewController.m b/Riot/Modules/Authentication/AuthenticationViewController.m index 1f26b020a..93f8362ff 100644 --- a/Riot/Modules/Authentication/AuthenticationViewController.m +++ b/Riot/Modules/Authentication/AuthenticationViewController.m @@ -554,6 +554,12 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; - (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId { + // The presenter isn't dismissed automatically when finishing via a deep link + if (self.ssoAuthenticationPresenter) + { + [self dismissSSOAuthenticationPresenter]; + } + // Check if transaction id is the same as expected if (loginToken && txnId && self.ssoCallbackTxnId diff --git a/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift b/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift index 84957a7a1..ec614b8b9 100644 --- a/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift @@ -73,8 +73,10 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator // MARK: - Public func start() { - // Listen to the end of the authentication flow + // Listen to the end of the authentication flow. authenticationViewController.authVCDelegate = self + // Listen for changes from deep links. + AuthenticationService.shared.delegate = self } func toPresentable() -> UIViewController { @@ -97,10 +99,6 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator authenticationViewController.showCustomHomeserver(homeserver, andIdentityServer: identityServer) } - func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool { - authenticationViewController.continueSSOLogin(withToken: loginToken, txnId: transactionID) - } - func presentPendingScreensIfNecessary() { canPresentAdditionalScreens = true @@ -147,6 +145,13 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator } } +// MARK: - AuthenticationServiceDelegate +extension LegacyAuthenticationCoordinator: AuthenticationServiceDelegate { + func authenticationService(_ service: AuthenticationService, didReceive ssoLoginToken: String, with transactionID: String) -> Bool { + authenticationViewController.continueSSOLogin(withToken: ssoLoginToken, txnId: transactionID) + } +} + // MARK: - AuthenticationViewControllerDelegate extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate { func authenticationViewController(_ authenticationViewController: AuthenticationViewController, diff --git a/Riot/Modules/Authentication/SSO/SSOAuthenticationPresenter.swift b/Riot/Modules/Authentication/SSO/SSOAuthenticationPresenter.swift index 2f7c0e1a0..1145d8651 100644 --- a/Riot/Modules/Authentication/SSO/SSOAuthenticationPresenter.swift +++ b/Riot/Modules/Authentication/SSO/SSOAuthenticationPresenter.swift @@ -72,17 +72,12 @@ final class SSOAuthenticationPresenter: NSObject { self.identityProvider = identityProvider self.presentingViewController = presentingViewController - // NOTE: By using SFAuthenticationSession the consent alert show product name instead of display name. Fallback to SFSafariViewController instead in order to not disturb users with "Riot" wording at the moment. - // (https://stackoverflow.com/questions/49860338/why-does-sfauthenticationsession-consent-alert-show-xcode-project-name-instead-o) - if #available(iOS 13.0, *) { + if #unavailable(iOS 15.0), UIAccessibility.isGuidedAccessEnabled { // SFAuthenticationSession and ASWebAuthenticationSession doesn't work with guided access (rdar://48376122) - if UIAccessibility.isGuidedAccessEnabled { - self.presentSafariViewController(with: authenticationURL, animated: animated) - } else { - self.startAuthenticationSession(with: authenticationURL) - } + // Confirmed to be fixed on iOS 15, haven't been able to test on iOS 14. + presentSafariViewController(with: authenticationURL, animated: animated) } else { - self.presentSafariViewController(with: authenticationURL, animated: animated) + startAuthenticationSession(with: authenticationURL) } } diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index d57b1de80..da0b23803 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -44,8 +44,11 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc /// The initial screen to be shown when starting the coordinator. private let initialScreen: EntryPoint + /// The presenter used to handler authentication via SSO. private var ssoAuthenticationPresenter: SSOAuthenticationPresenter? + /// The transaction ID used when presenting the SSO screen. Used when completing via a deep link. + private var ssoTransactionID: String? /// Whether the coordinator can present further screens after a successful login has occurred. private var canPresentAdditionalScreens: Bool @@ -79,9 +82,10 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc // MARK: - Public func start() { - Task { + Task { @MainActor in await startAuthenticationFlow() - await MainActor.run { callback?(.didStart) } + callback?(.didStart) + authenticationService.delegate = self } } @@ -469,17 +473,16 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc extension AuthenticationCoordinator: SSOAuthenticationPresenterDelegate { /// Presents SSO authentication for the specified identity provider. - private func presentSSOAuthentication(for identityProvider: SSOIdentityProvider) { + @MainActor private func presentSSOAuthentication(for identityProvider: SSOIdentityProvider) { let service = SSOAuthenticationService(homeserverStringURL: authenticationService.state.homeserver.address) let presenter = SSOAuthenticationPresenter(ssoAuthenticationService: service) presenter.delegate = self - presenter.present(forIdentityProvider: identityProvider, - with: MXTools.generateTransactionId(), - from: toPresentable(), - animated: true) + let transactionID = MXTools.generateTransactionId() + presenter.present(forIdentityProvider: identityProvider, with: transactionID, from: toPresentable(), animated: true) - self.ssoAuthenticationPresenter = presenter + ssoAuthenticationPresenter = presenter + ssoTransactionID = transactionID } func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationSucceededWithToken token: String, usingIdentityProvider identityProvider: SSOIdentityProvider?) { @@ -490,11 +493,7 @@ extension AuthenticationCoordinator: SSOAuthenticationPresenterDelegate { return } - Task { - let session = try await loginWizard.login(with: token) - await onSessionCreated(session: session, flow: authenticationService.state.flow) - self.ssoAuthenticationPresenter = nil - } + Task { await handleLoginToken(token, using: loginWizard) } } func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationDidFailWithError error: Error) { @@ -502,13 +501,50 @@ extension AuthenticationCoordinator: SSOAuthenticationPresenterDelegate { Task { await displayError(message: error.localizedDescription) - self.ssoAuthenticationPresenter = nil + ssoAuthenticationPresenter = nil + ssoTransactionID = nil } } func ssoAuthenticationPresenterDidCancel(_ presenter: SSOAuthenticationPresenter) { MXLog.debug("[AuthenticationCoordinator] SSO authentication cancelled.") - self.ssoAuthenticationPresenter = nil + ssoAuthenticationPresenter = nil + } + + /// Performs the last step of the login process for a flow that authenticated via SSO. + @MainActor private func handleLoginToken(_ token: String, using loginWizard: LoginWizard) async { + do { + let session = try await loginWizard.login(with: token) + onSessionCreated(session: session, flow: authenticationService.state.flow) + } catch { + MXLog.error("[AuthenticationCoordinator] Login with SSO token failed.") + displayError(message: error.localizedDescription) + } + + ssoAuthenticationPresenter = nil + ssoTransactionID = nil + } +} + +// MARK: - AuthenticationServiceDelegate +extension AuthenticationCoordinator: AuthenticationServiceDelegate { + func authenticationService(_ service: AuthenticationService, didReceive ssoLoginToken: String, with transactionID: String) -> Bool { + guard let presenter = ssoAuthenticationPresenter, transactionID == ssoTransactionID else { + Task { await displayError(message: VectorL10n.errorCommonMessage) } + return false + } + + guard let loginWizard = authenticationService.loginWizard else { + MXLog.failure("[AuthenticationCoordinator] The login wizard was requested before getting the login flow.") + return false + } + + Task { + await handleLoginToken(ssoLoginToken, using: loginWizard) + await MainActor.run { presenter.dismiss(animated: true, completion: nil) } + } + + return true } } @@ -565,9 +601,4 @@ extension AuthenticationCoordinator { func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?) { // unused } - - func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool { - #warning("To be implemented elsewhere") - return false - } } diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 4eae6588c..3521d7ee2 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -128,12 +128,6 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { legacyAuthenticationCoordinator.updateHomeserver(homeserver, andIdentityServer: identityServer) } - /// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters. - func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool { - guard isShowingLegacyAuthentication else { return false } - return legacyAuthenticationCoordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID) - } - // MARK: - Pre-Authentication /// Show the onboarding splash screen as the root module in the flow. diff --git a/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift b/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift index 2286bc046..8358cd8c1 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift @@ -97,12 +97,6 @@ final class OnboardingCoordinatorBridgePresenter: NSObject { coordinator?.updateHomeserver(homeserver, andIdentityServer: identityServer) } - /// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters. - func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool { - guard let coordinator = coordinator else { return false } - return coordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID) - } - func dismiss(animated: Bool, completion: (() -> Void)?) { guard let coordinator = self.coordinator else { return diff --git a/Riot/Modules/Onboarding/OnboardingCoordinatorProtocol.swift b/Riot/Modules/Onboarding/OnboardingCoordinatorProtocol.swift index 83954667a..597347bb0 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinatorProtocol.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinatorProtocol.swift @@ -27,7 +27,4 @@ protocol OnboardingCoordinatorProtocol: Coordinator, Presentable { /// Set up the authentication screen with the specified homeserver and/or identity server. func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?) - - /// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters. - func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index fa78a8048..4de93e593 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -17,7 +17,14 @@ import Foundation protocol AuthenticationServiceDelegate: AnyObject { - func authenticationServiceDidUpdateRegistrationParameters(_ authenticationService: AuthenticationService) + /// The authentication service received an SSO login token via a deep link. + /// This only occurs when SSOAuthenticationPresenter uses an SFSafariViewController. + /// - Parameters: + /// - service: The authentication service. + /// - ssoLoginToken: The login token provided when SSO succeeded. + /// - transactionID: The transaction ID generated during SSO page presentation. + /// - Returns: `true` if the SSO login can be continued. + func authenticationService(_ service: AuthenticationService, didReceive ssoLoginToken: String, with transactionID: String) -> Bool } class AuthenticationService: NSObject { @@ -43,6 +50,9 @@ class AuthenticationService: NSObject { /// The current registration wizard or `nil` if `startFlow` hasn't been called for `.registration`. private(set) var registrationWizard: RegistrationWizard? + /// The authentication service's delegate. + weak var delegate: AuthenticationServiceDelegate? + // MARK: - Setup override init() { @@ -108,11 +118,6 @@ class AuthenticationService: NSObject { self.client = client } - /// Get a SSO url - func getSSOURL(redirectUrl: String, deviceId: String?, providerId: String?) -> String? { - fatalError("Not implemented.") - } - /// Get the sign in or sign up fallback URL func fallbackURL(for flow: AuthenticationFlow) -> URL { switch flow { @@ -138,9 +143,13 @@ class AuthenticationService: NSObject { self.state = AuthenticationState(flow: .login, homeserverAddress: address) } - /// Create a session after a SSO successful login - func makeSessionFromSSO(credentials: MXCredentials) -> MXSession { - sessionCreator.createSession(credentials: credentials, client: client) + /// Continues an SSO flow when completion comes via a deep link. + /// - Parameters: + /// - token: The login token provided when SSO succeeded. + /// - transactionID: The transaction ID generated during SSO page presentation. + /// - Returns: `true` if the SSO login can be continued. + func continueSSOLogin(with token: String, and transactionID: String) -> Bool { + delegate?.authenticationService(self, didReceive: token, with: transactionID) ?? false } // /// Perform a well-known request, using the domain from the matrixId diff --git a/changelog.d/5654.wip b/changelog.d/5654.wip index 6f669d34a..14cfefb08 100644 --- a/changelog.d/5654.wip +++ b/changelog.d/5654.wip @@ -1 +1 @@ -Authentication: Add the login screen to the new flow. +Authentication: Add the login screen to the new flow and support SSO on both login and registration flows. From bf01ab32bb4a071265aa86f19cca7dc686801359 Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 25 May 2022 17:38:10 +0100 Subject: [PATCH 12/30] Update CI to use macOS 12 and Xcode 13.4. --- .github/workflows/ci-build.yml | 2 +- .github/workflows/ci-tests.yml | 2 +- .github/workflows/ci-ui-tests.yml | 2 +- .github/workflows/release-alpha.yml | 4 ++-- changelog.d/pr-6204.build | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 changelog.d/pr-6204.build diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 46a6fab45..e11eca47a 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -15,7 +15,7 @@ env: jobs: build: name: Build - runs-on: macos-11 + runs-on: macos-12 # Concurrency group not needed as this workflow only runs on develop which we always want to test. diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index fa3af8d1d..baba82853 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -16,7 +16,7 @@ env: jobs: tests: name: Tests - runs-on: macos-11 + runs-on: macos-12 concurrency: # When running on develop, use the sha to allow all runs of this workflow to run concurrently. diff --git a/.github/workflows/ci-ui-tests.yml b/.github/workflows/ci-ui-tests.yml index fb65d471d..28d7a2894 100644 --- a/.github/workflows/ci-ui-tests.yml +++ b/.github/workflows/ci-ui-tests.yml @@ -14,7 +14,7 @@ env: jobs: tests: name: UI Tests - runs-on: macos-11 + runs-on: macos-12 concurrency: # Only allow a single run of this workflow on each branch, automatically cancelling older runs. diff --git a/.github/workflows/release-alpha.yml b/.github/workflows/release-alpha.yml index d8222469f..889f57a75 100644 --- a/.github/workflows/release-alpha.yml +++ b/.github/workflows/release-alpha.yml @@ -14,7 +14,7 @@ env: jobs: check-secret: - runs-on: macos-11 + runs-on: macos-12 outputs: out-key: ${{ steps.out-key.outputs.defined }} steps: @@ -29,7 +29,7 @@ jobs: needs: [check-secret] if: needs.check-secret.outputs.out-key == 'true' name: Release - runs-on: macos-11 + runs-on: macos-12 concurrency: # Only allow a single run of this workflow on each branch, automatically cancelling older runs. diff --git a/changelog.d/pr-6204.build b/changelog.d/pr-6204.build new file mode 100644 index 000000000..cb30f7c8b --- /dev/null +++ b/changelog.d/pr-6204.build @@ -0,0 +1 @@ +CI: Use macOS 12 and Xcode 13.4 From 446ddf5aeb72d0b42cec9c2514422dc4c05492c8 Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 26 May 2022 14:46:33 +0100 Subject: [PATCH 13/30] Report analytics from the new auth flow. Fix SSO buttons pressed appearance. --- .../AuthenticationCoordinator.swift | 23 ++++++++++----- .../AuthenticationLoginCoordinator.swift | 4 +-- ...uthenticationRegistrationCoordinator.swift | 4 +-- .../Util/SecondaryActionButtonStyle.swift | 29 +++++++++---------- 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index da0b23803..9aa72be8f 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -44,6 +44,8 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc /// The initial screen to be shown when starting the coordinator. private let initialScreen: EntryPoint + /// The type of authentication that was used to complete the flow. + private var authenticationType: AuthenticationType? /// The presenter used to handler authentication via SSO. private var ssoAuthenticationPresenter: SSOAuthenticationPresenter? @@ -189,7 +191,9 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc switch result { case .continueWithSSO(let provider): presentSSOAuthentication(for: provider) - case .success(let session): + case .success(let session, let loginPassword): + password = loginPassword + authenticationType = .password onSessionCreated(session: session, flow: .login) } } @@ -265,7 +269,9 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc switch result { case .continueWithSSO(let provider): presentSSOAuthentication(for: provider) - case .completed(let result): + case .completed(let result, let registerPassword): + password = registerPassword + authenticationType = .password handleRegistrationResult(result) } } @@ -397,7 +403,6 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc /// Handles the creation of a new session following on from a successful authentication. @MainActor private func onSessionCreated(session: MXSession, flow: AuthenticationFlow) { self.session = session - // self.password = password if canPresentAdditionalScreens { showLoadingAnimation() @@ -425,8 +430,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc verificationListener.start() self.verificationListener = verificationListener - #warning("Add authentication type to the new flow.") - callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: .other)) + callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: authenticationType ?? .other)) } // MARK: - Additional Screens @@ -483,6 +487,7 @@ extension AuthenticationCoordinator: SSOAuthenticationPresenterDelegate { ssoAuthenticationPresenter = presenter ssoTransactionID = transactionID + authenticationType = .sso(identityProvider) } func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationSucceededWithToken token: String, usingIdentityProvider identityProvider: SSOIdentityProvider?) { @@ -499,16 +504,19 @@ extension AuthenticationCoordinator: SSOAuthenticationPresenterDelegate { func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationDidFailWithError error: Error) { MXLog.debug("[AuthenticationCoordinator] SSO authentication failed.") - Task { - await displayError(message: error.localizedDescription) + Task { @MainActor in + displayError(message: error.localizedDescription) ssoAuthenticationPresenter = nil ssoTransactionID = nil + authenticationType = nil } } func ssoAuthenticationPresenterDidCancel(_ presenter: SSOAuthenticationPresenter) { MXLog.debug("[AuthenticationCoordinator] SSO authentication cancelled.") ssoAuthenticationPresenter = nil + ssoTransactionID = nil + authenticationType = nil } /// Performs the last step of the login process for a flow that authenticated via SSO. @@ -519,6 +527,7 @@ extension AuthenticationCoordinator: SSOAuthenticationPresenterDelegate { } catch { MXLog.error("[AuthenticationCoordinator] Login with SSO token failed.") displayError(message: error.localizedDescription) + authenticationType = nil } ssoAuthenticationPresenter = nil diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index 3e465f124..7de6554e8 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -29,7 +29,7 @@ enum AuthenticationLoginCoordinatorResult { /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) /// Login was successful with the associated session created. - case success(MXSession) + case success(session: MXSession, password: String) } final class AuthenticationLoginCoordinator: Coordinator, Presentable { @@ -144,7 +144,7 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { initialDeviceName: UIDevice.current.initialDisplayName) guard !Task.isCancelled else { return } - callback?(.success(session)) + callback?(.success(session: session, password: password)) self?.stopLoading() } catch { diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index da3f641cd..e14fb538a 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -31,7 +31,7 @@ enum AuthenticationRegistrationCoordinatorResult { /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) /// The screen completed with the associated registration result. - case completed(RegistrationResult) + case completed(result: RegistrationResult, password: String) } final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { @@ -160,7 +160,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { initialDeviceDisplayName: UIDevice.current.initialDisplayName) guard !Task.isCancelled else { return } - callback?(.completed(result)) + callback?(.completed(result: result, password: password)) self?.stopLoading() } catch { diff --git a/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift b/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift index b6051b39b..2d081dd2d 100644 --- a/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift +++ b/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift @@ -27,25 +27,24 @@ struct SecondaryActionButtonStyle: ButtonStyle { configuration.label .padding(12.0) .frame(maxWidth: .infinity) - .foregroundColor(strokeColor(configuration.isPressed)) + .foregroundColor(customColor ?? theme.colors.accent) .font(theme.fonts.body) .background(RoundedRectangle(cornerRadius: 8) .strokeBorder() - .foregroundColor(strokeColor(configuration.isPressed))) - .opacity(isEnabled ? 1.0 : 0.6) + .foregroundColor(customColor ?? theme.colors.accent)) + .opacity(opacity(when: configuration.isPressed)) } - func strokeColor(_ isPressed: Bool) -> Color { - if let customColor = customColor { - return customColor - } - - return isPressed ? theme.colors.accent.opacity(0.6) : theme.colors.accent + private func opacity(when isPressed: Bool) -> CGFloat { + guard isEnabled else { return 0.6 } + return isPressed ? 0.6 : 1.0 } } @available(iOS 14.0, *) struct SecondaryActionButtonStyle_Previews: PreviewProvider { + static var theme: ThemeSwiftUI = DefaultThemeSwiftUI() + static var previews: some View { Group { buttonGroup @@ -64,14 +63,14 @@ struct SecondaryActionButtonStyle_Previews: PreviewProvider { .buttonStyle(SecondaryActionButtonStyle()) .disabled(true) - Button { } label: { - Text("Clear BG") - .foregroundColor(.red) - } - .buttonStyle(SecondaryActionButtonStyle(customColor: .clear)) - Button("Red BG") { } .buttonStyle(SecondaryActionButtonStyle(customColor: .red)) + + Button { } label: { + Text("Custom") + .foregroundColor(theme.colors.secondaryContent) + } + .buttonStyle(SecondaryActionButtonStyle(customColor: theme.colors.quarterlyContent)) } .padding() } From 88b2688703b8f82fdd40e24ba21d2c3ef473b161 Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Fri, 27 May 2022 17:49:54 +0300 Subject: [PATCH 14/30] Show fallback on authentication coordinator --- .../AuthenticationCoordinator.swift | 103 ++++++++++++++---- .../MatrixSDK/AuthenticationService.swift | 4 +- .../MatrixSDK/AuthenticationState.swift | 19 ++++ 3 files changed, 102 insertions(+), 24 deletions(-) diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index 9aa72be8f..39e3d686e 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -110,23 +110,29 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc /// Starts the authentication flow. @MainActor private func startAuthenticationFlow() async { - do { - let flow: AuthenticationFlow = initialScreen == .login ? .login : .register - let homeserverAddress = authenticationService.state.homeserver.addressFromUser ?? authenticationService.state.homeserver.address - try await authenticationService.startFlow(flow, for: homeserverAddress) - } catch { - MXLog.error("[AuthenticationCoordinator] start: Failed to start") - displayError(message: error.localizedDescription) - return + let flow: AuthenticationFlow = initialScreen == .login ? .login : .register + if initialScreen != .selectServerForRegistration { + do { + let homeserverAddress = authenticationService.state.homeserver.addressFromUser ?? authenticationService.state.homeserver.address + try await authenticationService.startFlow(flow, for: homeserverAddress) + } catch { + MXLog.error("[AuthenticationCoordinator] start: Failed to start") + displayError(message: error.localizedDescription) + return + } } - - switch initialScreen { - case .registration: - showRegistrationScreen() - case .selectServerForRegistration: - showServerSelectionScreen() - case .login: - showLoginScreen() + + if authenticationService.state.homeserver.needsFallback { + showFallback(for: flow) + } else { + switch initialScreen { + case .registration: + showRegistrationScreen() + case .selectServerForRegistration: + showServerSelectionScreen() + case .login: + showLoginScreen() + } } } @@ -192,8 +198,12 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc case .continueWithSSO(let provider): presentSSOAuthentication(for: provider) case .success(let session, let loginPassword): - password = loginPassword - authenticationType = .password + if let loginPassword = loginPassword { + password = loginPassword + authenticationType = .password + } else { + authenticationType = .other + } onSessionCreated(session: session, flow: .login) } } @@ -231,7 +241,11 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) { switch result { case .updated: - showRegistrationScreen() + if authenticationService.state.homeserver.needsFallback { + showFallback(for: .register) + } else { + showRegistrationScreen() + } case .dismiss: MXLog.failure("[AuthenticationCoordinator] AuthenticationServerSelectionScreen is requesting dismiss when part of a stack.") } @@ -395,8 +409,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc case .dummy: MXLog.failure("[AuthenticationCoordinator] Attempting to perform the dummy stage.") case .other: - #warning("Show fallback") - MXLog.failure("[AuthenticationCoordinator] Attempting to perform an unsupported stage.") + showFallback(for: .register) } } @@ -434,6 +447,34 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } // MARK: - Additional Screens + + private func showFallback(for flow: AuthenticationFlow) { + let url = authenticationService.fallbackURL(for: flow) + + MXLog.debug("[AuthenticationCoordinator] showFallback for: \(flow), url: \(url)") + + guard let fallbackVC = AuthFallBackViewController(url: url.absoluteString) else { + MXLog.error("[AuthenticationCoordinator] showFallback: could not create fallback view controller") + return + } + fallbackVC.delegate = self + let navController = RiotNavigationController(rootViewController: fallbackVC) + navController.navigationBar.topItem?.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, + action: #selector(dismissFallback)) + navigationRouter.present(navController, animated: true) + } + + @objc + private func dismissFallback() { + MXLog.debug("[AuthenticationCoorrdinator] dismissFallback") + + guard let fallbackNavigationVC = navigationRouter.toPresentable().presentedViewController as? RiotNavigationController else { + return + } + fallbackNavigationVC.dismiss(animated: true) + authenticationService.reset() + } /// Replace the contents of the navigation router with a loading animation. private func showLoadingAnimation() { @@ -611,3 +652,23 @@ extension AuthenticationCoordinator { // unused } } + +// MARK: - AuthFallBackViewControllerDelegate +extension AuthenticationCoordinator: AuthFallBackViewControllerDelegate { + @MainActor func authFallBackViewController(_ authFallBackViewController: AuthFallBackViewController, + didLoginWith loginResponse: MXLoginResponse) { + let credentials = MXCredentials(loginResponse: loginResponse, andDefaultCredentials: nil) + let client = MXRestClient(credentials: credentials) + guard let session = MXSession(matrixRestClient: client) else { + MXLog.error("[AuthenticationCoordinator] authFallBackViewController:didLogin: session could not be created") + return + } + let flow: AuthenticationFlow = initialScreen == .login ? .login : .register + onSessionCreated(session: session, flow: flow) + } + + func authFallBackViewControllerDidClose(_ authFallBackViewController: AuthFallBackViewController) { + dismissFallback() + } + +} diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 4de93e593..9571b27bd 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -138,9 +138,7 @@ class AuthenticationService: NSObject { loginWizard = nil registrationWizard = nil - // The previously used homeserver is re-used as `startFlow` will be called again a replace it anyway. - let address = state.homeserver.addressFromUser ?? state.homeserver.address - self.state = AuthenticationState(flow: .login, homeserverAddress: address) + self.state = AuthenticationState(flow: .login, homeserverAddress: state.homeserver.address) } /// Continues an SSO flow when completion comes via a deep link. diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift index dbfcaf26d..9b1b7afe9 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift @@ -66,5 +66,24 @@ struct AuthenticationState { showRegistrationForm: registrationFlow != nil, ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? []) } + + /// Needs authentication fallback for login or registration + var needsFallback: Bool { + switch preferredLoginMode { + case .unsupported: + return true + default: + break + } + guard let flow = registrationFlow else { + return false + } + switch flow { + case .flowResponse(let result): + return result.needsFallback + default: + return false + } + } } } From dc4845618caabc7ccd0380e4d603e17ec9a83b0f Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Fri, 27 May 2022 17:51:54 +0300 Subject: [PATCH 15/30] Show fallback on login screen --- .../AuthenticationHomeserverViewData.swift | 10 +++ .../Login/AuthenticationLoginModels.swift | 4 ++ .../Login/AuthenticationLoginViewModel.swift | 2 + .../AuthenticationLoginCoordinator.swift | 65 +++++++++++++++++-- .../MockAuthenticationLoginScreenState.swift | 3 + .../Test/UI/AuthenticationLoginUITests.swift | 18 +++++ .../AuthenticationLoginViewModelTests.swift | 12 ++++ .../View/AuthenticationLoginScreen.swift | 18 +++++ 8 files changed, 127 insertions(+), 5 deletions(-) diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift index 484ba1f7b..4b56563c3 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift @@ -65,4 +65,14 @@ extension AuthenticationHomeserverViewData { showRegistrationForm: false, ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) } + + /// A mock homeserver that supports only supports authentication via fallback. + static var mockFallback: AuthenticationHomeserverViewData { + AuthenticationHomeserverViewData(address: "company.com", + isMatrixDotOrg: false, + showLoginForm: false, + showRegistrationForm: false, + ssoIdentityProviders: []) + } + } diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift index 8d6b564a4..049bfb65e 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -29,6 +29,8 @@ enum AuthenticationLoginViewModelResult { case login(username: String, password: String) /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) + /// Continue using the fallback page + case fallback } // MARK: View @@ -70,6 +72,8 @@ enum AuthenticationLoginViewAction { case forgotPassword /// Continue using the input username and password. case next + /// Continue using the fallback page + case fallback /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) } diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift index d3c73495f..aa73f0586 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -49,6 +49,8 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica Task { await callback?(.forgotPassword) } case .next: Task { await callback?(.login(username: state.bindings.username, password: state.bindings.password)) } + case .fallback: + Task { await callback?(.fallback) } case .continueWithSSO(let provider): Task { await callback?(.continueWithSSO(provider))} } diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index 7de6554e8..e32c8005f 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -29,7 +29,7 @@ enum AuthenticationLoginCoordinatorResult { /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) /// Login was successful with the associated session created. - case success(session: MXSession, password: String) + case success(session: MXSession, password: String?) } final class AuthenticationLoginCoordinator: Coordinator, Presentable { @@ -109,6 +109,8 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { self.login(username: username, password: password) case .continueWithSSO(let identityProvider): self.callback?(.continueWithSSO(identityProvider)) + case .fallback: + self.showFallback() } } } @@ -222,11 +224,11 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { /// Handles the result from the server selection modal, dismissing it after updating the view. @MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator, didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) { - if result == .updated { - updateViewModel() - } - navigationRouter.dismissModule(animated: true) { [weak self] in + if result == .updated { + self?.updateViewModel() + } + self?.remove(childCoordinator: coordinator) } } @@ -235,5 +237,58 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { @MainActor private func updateViewModel() { let homeserver = authenticationService.state.homeserver authenticationLoginViewModel.update(homeserver: homeserver.viewData) + + if homeserver.needsFallback { + showFallback() + } + } + + private func showFallback() { + let url = authenticationService.fallbackURL(for: .login) + + MXLog.debug("[AuthenticationLoginCoordinator] showFallback, url: \(url)") + + guard let fallbackVC = AuthFallBackViewController(url: url.absoluteString) else { + MXLog.error("[AuthenticationLoginCoordinator] showFallback: could not create fallback view controller") + return + } + fallbackVC.delegate = self + let navController = RiotNavigationController(rootViewController: fallbackVC) + navController.navigationBar.topItem?.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, + action: #selector(dismissFallback)) + navigationRouter.present(navController, animated: true) + } + + @objc + private func dismissFallback() { + MXLog.debug("[AuthenticationLoginCoorrdinator] dismissFallback") + + guard let fallbackNavigationVC = navigationRouter.toPresentable().presentedViewController as? RiotNavigationController else { + return + } + fallbackNavigationVC.dismiss(animated: true) + authenticationService.reset() } } + + +// MARK: - AuthFallBackViewControllerDelegate +extension AuthenticationLoginCoordinator: AuthFallBackViewControllerDelegate { + @MainActor func authFallBackViewController(_ authFallBackViewController: AuthFallBackViewController, + didLoginWith loginResponse: MXLoginResponse) { + let credentials = MXCredentials(loginResponse: loginResponse, andDefaultCredentials: nil) + let client = MXRestClient(credentials: credentials) + guard let session = MXSession(matrixRestClient: client) else { + MXLog.error("[AuthenticationCoordinator] authFallBackViewController:didLogin: session could not be created") + return + } + dismissFallback() + callback?(.success(session: session, password: nil)) + } + + func authFallBackViewControllerDidClose(_ authFallBackViewController: AuthFallBackViewController) { + dismissFallback() + } + +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift index fd0afb87d..1e242c7e1 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift @@ -27,6 +27,7 @@ enum MockAuthenticationLoginScreenState: MockScreenState, CaseIterable { case passwordOnly case passwordWithCredentials case ssoOnly + case fallback /// The associated screen var screenType: Any.Type { @@ -47,6 +48,8 @@ enum MockAuthenticationLoginScreenState: MockScreenState, CaseIterable { viewModel.context.password = "password" case .ssoOnly: viewModel = AuthenticationLoginViewModel(homeserver: .mockEnterpriseSSO) + case .fallback: + viewModel = AuthenticationLoginViewModel(homeserver: .mockFallback) } diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift index f5a00f526..3bfda9406 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift @@ -50,6 +50,9 @@ class AuthenticationLoginUITests: MockScreenTest { validateServerDescriptionIsHidden(for: state) validateLoginFormIsHidden(for: state) validateSSOButtonsAreShown(for: state) + case .fallback: + let state = "a fallback server" + validateFallback(for: state) } } @@ -114,5 +117,20 @@ class AuthenticationLoginUITests: MockScreenTest { XCTAssertTrue(nextButton.exists, "The next button should be shown.") XCTAssertTrue(nextButton.isEnabled, "The next button should be enabled for \(state).") } + + func validateFallback(for state: String) { + let usernameTextField = app.textFields.element + let passwordTextField = app.secureTextFields.element + let nextButton = app.buttons["nextButton"] + let ssoButtons = app.buttons.matching(identifier: "ssoButton") + let fallbackButton = app.buttons["fallbackButton"] + + XCTAssertFalse(usernameTextField.exists, "Username input should not be shown for \(state).") + XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).") + XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).") + XCTAssertEqual(ssoButtons.count, 0, "There should not be any SSO buttons shown for \(state).") + XCTAssertTrue(fallbackButton.exists, "The fallback button should be shown for \(state).") + XCTAssertTrue(fallbackButton.isEnabled, "The fallback button should be enabled for \(state).") + } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift index 671ccb733..013eb0ec7 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift @@ -110,4 +110,16 @@ class AuthenticationLoginViewModelTests: XCTestCase { // 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.") } + + @MainActor func testFallbackServer() { + // Given a basic server example.com that only supports password registration. + let homeserver = AuthenticationHomeserverViewData.mockFallback + + // When updating the view model with the server. + viewModel.update(homeserver: homeserver) + + // Then the view state should be updated with the homeserver and hide the SSO buttons and login form. + XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.") + XCTAssertFalse(context.viewState.homeserver.showLoginForm, "The login form should not be shown.") + } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift index a2b9aaf11..81f9620e0 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -61,6 +61,10 @@ struct AuthenticationLoginScreen: View { ssoButtons .padding(.top, 16) } + + if !viewModel.viewState.homeserver.showLoginForm && !viewModel.viewState.showSSOButtons { + fallbackButton + } } .readableFrame() @@ -137,6 +141,15 @@ struct AuthenticationLoginScreen: View { } } } + + /// A fallback button that can be used for login. + var fallbackButton: some View { + Button(action: fallback) { + Text(VectorL10n.login) + } + .buttonStyle(PrimaryActionButtonStyle()) + .accessibilityIdentifier("fallbackButton") + } /// Give focus to the password text field. func usernameEditingChanged(isEditing: Bool) { @@ -158,6 +171,11 @@ struct AuthenticationLoginScreen: View { guard viewModel.viewState.hasValidCredentials else { return } viewModel.send(viewAction: .next) } + + /// Sends the `fallback` view action. + func fallback() { + viewModel.send(viewAction: .fallback) + } } // MARK: - Previews From b35639bdcfc7e543a212beb336d33b6b8d953fdf Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Fri, 27 May 2022 17:52:13 +0300 Subject: [PATCH 16/30] Do not start loading on server selection --- .../Coordinator/OnboardingUseCaseSelectionCoordinator.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift index d3ef2a79f..7c7382958 100644 --- a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift @@ -57,7 +57,9 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable { MXLog.debug("[OnboardingUseCaseSelectionCoordinator] OnboardingUseCaseViewModel did complete with result: \(result).") // Show a loading indicator which can be dismissed externally by calling `stop`. - self.startLoading() + if result != .customServer { + self.startLoading() + } self.completion?(result) } } From 8441386c2ed734792994c252d5e3b34963126b3a Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Fri, 27 May 2022 17:54:23 +0300 Subject: [PATCH 17/30] Add changelog --- changelog.d/6176.change | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/6176.change diff --git a/changelog.d/6176.change b/changelog.d/6176.change new file mode 100644 index 000000000..6c9cd693a --- /dev/null +++ b/changelog.d/6176.change @@ -0,0 +1 @@ +Authentication: Display fallback screens on registration & login according to the HS needs. From 4a2ed356582e09284facd3abe08d34837f1c7e32 Mon Sep 17 00:00:00 2001 From: Doug Date: Sun, 29 May 2022 10:14:14 +0100 Subject: [PATCH 18/30] Login/Registration screen tweaks Scale SSO button icon with dynamic type. Add an onCommit parameter to RoundedBorderTextField and use to submit instead of onEditingChanged. --- .../Common/AuthenticationSSOButton.swift | 62 +++++++++++++++---- .../View/AuthenticationLoginScreen.swift | 12 ++-- .../AuthenticationRegistrationScreen.swift | 14 ++--- .../Common/Util/ClearViewModifier.swift | 18 ++++-- .../Common/Util/RoundedBorderTextField.swift | 42 +++++-------- 5 files changed, 93 insertions(+), 55 deletions(-) diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift index ba7e8b099..767ed7d18 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift @@ -16,7 +16,7 @@ import SwiftUI -/// An button that displays the icon and name of an SSO provider. +/// A button that displays the icon and name of an SSO provider. struct AuthenticationSSOButton: View { // MARK: - Constants @@ -28,6 +28,11 @@ struct AuthenticationSSOButton: View { // MARK: - Private @Environment(\.theme) private var theme + @ScaledMetric private var iconSize = 24 + + private var renderingMode: Image.TemplateRenderingMode? { + provider.brand == Brand.apple.rawValue || provider.brand == Brand.github.rawValue ? .template : nil + } // MARK: - Public @@ -52,32 +57,63 @@ struct AuthenticationSSOButton: View { .opacity(0) } .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) .contentShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(SecondaryActionButtonStyle(customColor: theme.colors.quinaryContent)) } - @ViewBuilder + /// The icon with appropriate rendering mode and size for dynamic type. var icon: some View { + iconImage.map { image in + image + .renderingMode(renderingMode) + .resizable() + .scaledToFit() + .frame(width: iconSize, height: iconSize) + .foregroundColor(renderingMode == .template ? theme.colors.primaryContent : nil) + } + } + + /// The image to be shown in the icon. + var iconImage: Image? { switch provider.brand { case Brand.apple.rawValue: - Image(Asset.Images.authenticationSsoIconApple.name) - .renderingMode(.template) - .foregroundColor(theme.colors.primaryContent) + return Image(Asset.Images.authenticationSsoIconApple.name) case Brand.facebook.rawValue: - Image(Asset.Images.authenticationSsoIconFacebook.name) + return Image(Asset.Images.authenticationSsoIconFacebook.name) case Brand.github.rawValue: - Image(Asset.Images.authenticationSsoIconGithub.name) - .renderingMode(.template) - .foregroundColor(theme.colors.primaryContent) + return Image(Asset.Images.authenticationSsoIconGithub.name) case Brand.gitlab.rawValue: - Image(Asset.Images.authenticationSsoIconGitlab.name) + return Image(Asset.Images.authenticationSsoIconGitlab.name) case Brand.google.rawValue: - Image(Asset.Images.authenticationSsoIconGoogle.name) + return Image(Asset.Images.authenticationSsoIconGoogle.name) case Brand.twitter.rawValue: - Image(Asset.Images.authenticationSsoIconTwitter.name) + return Image(Asset.Images.authenticationSsoIconTwitter.name) default: - EmptyView() + return nil } } } + +struct AuthenticationSSOButton_Previews: PreviewProvider { + static var matrixDotOrg = AuthenticationHomeserverViewData.mockMatrixDotOrg + + static var buttons: some View { + VStack { + ForEach(matrixDotOrg.ssoIdentityProviders) { provider in + AuthenticationSSOButton(provider: provider) { } + } + AuthenticationSSOButton(provider: SSOIdentityProvider(id: "", name: "SAML", brand: nil, iconURL: nil)) { } + } + .padding() + } + + static var previews: some View { + buttons + .theme(.light).preferredColorScheme(.light) + .environment(\.sizeCategory, .accessibilityLarge) + buttons + .theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift index a2b9aaf11..5e0feb264 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -97,7 +97,8 @@ struct AuthenticationLoginScreen: View { configuration: UIKitTextInputConfiguration(returnKeyType: .next, autocapitalizationType: .none, autocorrectionType: .no), - onEditingChanged: usernameEditingChanged) + onEditingChanged: usernameEditingChanged, + onCommit: { isPasswordFocused = true }) .accessibilityIdentifier("usernameTextField") Spacer().frame(height: 20) @@ -107,7 +108,8 @@ struct AuthenticationLoginScreen: View { isFirstResponder: isPasswordFocused, configuration: UIKitTextInputConfiguration(returnKeyType: .done, isSecureTextEntry: true), - onEditingChanged: passwordEditingChanged) + onEditingChanged: passwordEditingChanged, + onCommit: submit) .accessibilityIdentifier("passwordTextField") Button { viewModel.send(viewAction: .forgotPassword) } label: { @@ -138,19 +140,17 @@ struct AuthenticationLoginScreen: View { } } - /// Give focus to the password text field. + /// Parses the username for a homeserver. func usernameEditingChanged(isEditing: Bool) { guard !isEditing, !viewModel.username.isEmpty else { return } viewModel.send(viewAction: .parseUsername) - isPasswordFocused = true } - /// Submits the form if valid credentials have been input. + /// Resets the password field focus. func passwordEditingChanged(isEditing: Bool) { guard !isEditing else { return } isPasswordFocused = false - submit() } /// Sends the `next` view action so long as valid credentials have been input. diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift index 60bff52a4..3d7c022f6 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -108,7 +108,8 @@ struct AuthenticationRegistrationScreen: View { configuration: UIKitTextInputConfiguration(returnKeyType: .next, autocapitalizationType: .none, autocorrectionType: .no), - onEditingChanged: usernameEditingChanged) + onEditingChanged: usernameEditingChanged, + onCommit: { isPasswordFocused = true }) .onChange(of: viewModel.username) { _ in viewModel.send(viewAction: .clearUsernameError) } .accessibilityIdentifier("usernameTextField") @@ -120,7 +121,8 @@ struct AuthenticationRegistrationScreen: View { isFirstResponder: isPasswordFocused, configuration: UIKitTextInputConfiguration(returnKeyType: .done, isSecureTextEntry: true), - onEditingChanged: passwordEditingChanged) + onEditingChanged: passwordEditingChanged, + onCommit: submit) .accessibilityIdentifier("passwordTextField") Button(action: submit) { @@ -144,19 +146,17 @@ struct AuthenticationRegistrationScreen: View { } } - /// Validates the username when the text field ends editing, and selects the password text field. + /// Validates the username when the text field ends editing. func usernameEditingChanged(isEditing: Bool) { guard !isEditing, !viewModel.username.isEmpty else { return } - viewModel.send(viewAction: .validateUsername) - isPasswordFocused = true } - /// Enables password validation the first time the user taps return, and sends the username and submits the form if possible. + /// Enables password validation the first time the user finishes editing. + /// Additionally resets the password field focus. func passwordEditingChanged(isEditing: Bool) { guard !isEditing else { return } isPasswordFocused = false - submit() guard !viewModel.viewState.hasEditedPassword else { return } viewModel.send(viewAction: .enablePasswordValidation) diff --git a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift index ca404d749..bce16b4ff 100644 --- a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift +++ b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift @@ -18,15 +18,25 @@ import SwiftUI @available(iOS 14.0, *) extension ThemableTextField { - func showClearButton(text: Binding, alignement: VerticalAlignment = .center) -> some View { - return modifier(ClearViewModifier(alignment: alignement, text: text)) + /// Adds a clear button to the text field + /// - Parameters: + /// - show: A boolean that can be used to dynamically show/hide the button. Defaults to `true`. + /// - text: The text for the clear button to clear. + /// - alignment: The vertical alignment of the button in the text field. Default to `center` + @ViewBuilder + func showClearButton(_ show: Bool = true, text: Binding, alignment: VerticalAlignment = .center) -> some View { + if show { + modifier(ClearViewModifier(alignment: alignment, text: text)) + } else { + self + } } } @available(iOS 14.0, *) extension ThemableTextEditor { - func showClearButton(text: Binding, alignement: VerticalAlignment = .top) -> some View { - return modifier(ClearViewModifier(alignment: alignement, text: text)) + func showClearButton(text: Binding, alignment: VerticalAlignment = .top) -> some View { + return modifier(ClearViewModifier(alignment: alignment, text: text)) } } diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift index c9f3d73c3..f3ea7c89f 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -33,6 +33,7 @@ struct RoundedBorderTextField: View { var onTextChanged: ((String) -> Void)? = nil var onEditingChanged: ((Bool) -> Void)? = nil + var onCommit: (() -> Void)? = nil // MARK: Private @@ -52,6 +53,7 @@ struct RoundedBorderTextField: View { .multilineTextAlignment(.leading) .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) } + ZStack(alignment: .leading) { if text.isEmpty { Text(placeHolder) @@ -60,32 +62,22 @@ struct RoundedBorderTextField: View { .lineLimit(1) .accessibilityHidden(true) } - if isEnabled { - ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in - self.editing = edit - onEditingChanged?(edit) - }) - .makeFirstResponder(isFirstResponder) - .showClearButton(text: $text) - .onChange(of: text) { newText in - onTextChanged?(newText) - } - .frame(height: 30) - .accessibilityLabel(text.isEmpty ? placeHolder : "") - } else { - ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in - self.editing = edit - onEditingChanged?(edit) - }) - .makeFirstResponder(isFirstResponder) - .onChange(of: text) { newText in - onTextChanged?(newText) - } - .frame(height: 30) - .allowsHitTesting(false) - .opacity(0.5) - .accessibilityLabel(text.isEmpty ? placeHolder : "") + + ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in + self.editing = edit + onEditingChanged?(edit) + }, onCommit: { + onCommit?() + }) + .makeFirstResponder(isFirstResponder) + .showClearButton(isEnabled, text: $text) + .onChange(of: text) { newText in + onTextChanged?(newText) } + .frame(height: 30) + .allowsHitTesting(isEnabled) + .opacity(isEnabled ? 1 : 0.5) + .accessibilityLabel(text.isEmpty ? placeHolder : "") } .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: text.isEmpty ? 8 : 0)) .background(RoundedRectangle(cornerRadius: 8).fill(theme.colors.background)) From 15ebe174c5924660284f9f8866d81d13034f3f0d Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Mon, 30 May 2022 19:56:23 +0300 Subject: [PATCH 19/30] Fix PR remarks --- .../AuthenticationCoordinator.swift | 42 +++++++------ .../MatrixSDK/AuthenticationService.swift | 6 +- .../MatrixSDK/AuthenticationState.swift | 17 +++--- .../Service/MatrixSDK/LoginModels.swift | 9 +++ .../AuthenticationLoginCoordinator.swift | 59 ++----------------- .../AuthenticationRegistrationModels.swift | 4 ++ .../AuthenticationRegistrationViewModel.swift | 2 + ...uthenticationRegistrationCoordinator.swift | 4 ++ ...uthenticationRegistrationScreenState.swift | 3 + .../AuthenticationRegistrationUITests.swift | 25 ++++++++ ...enticationRegistrationViewModelTests.swift | 12 ++++ .../AuthenticationRegistrationScreen.swift | 18 ++++++ 12 files changed, 119 insertions(+), 82 deletions(-) diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index 39e3d686e..42f4e9446 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -122,15 +122,19 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } } - if authenticationService.state.homeserver.needsFallback { - showFallback(for: flow) - } else { - switch initialScreen { - case .registration: + switch initialScreen { + case .registration: + if authenticationService.state.homeserver.needsRegistrationFallback { + showFallback(for: flow) + } else { showRegistrationScreen() - case .selectServerForRegistration: - showServerSelectionScreen() - case .login: + } + case .selectServerForRegistration: + showServerSelectionScreen() + case .login: + if authenticationService.state.homeserver.needsLoginFallback { + showFallback(for: flow) + } else { showLoginScreen() } } @@ -198,13 +202,11 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc case .continueWithSSO(let provider): presentSSOAuthentication(for: provider) case .success(let session, let loginPassword): - if let loginPassword = loginPassword { - password = loginPassword - authenticationType = .password - } else { - authenticationType = .other - } + password = loginPassword + authenticationType = .password onSessionCreated(session: session, flow: .login) + case .fallback: + showFallback(for: .login) } } @@ -241,7 +243,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) { switch result { case .updated: - if authenticationService.state.homeserver.needsFallback { + if authenticationService.state.homeserver.needsRegistrationFallback { showFallback(for: .register) } else { showRegistrationScreen() @@ -287,6 +289,8 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc password = registerPassword authenticationType = .password handleRegistrationResult(result) + case .fallback: + showFallback(for: .register) } } @@ -409,6 +413,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc case .dummy: MXLog.failure("[AuthenticationCoordinator] Attempting to perform the dummy stage.") case .other: + MXLog.failure("[AuthenticationCoordinator] Attempting to perform an unsupported stage.") showFallback(for: .register) } } @@ -655,16 +660,17 @@ extension AuthenticationCoordinator { // MARK: - AuthFallBackViewControllerDelegate extension AuthenticationCoordinator: AuthFallBackViewControllerDelegate { - @MainActor func authFallBackViewController(_ authFallBackViewController: AuthFallBackViewController, + func authFallBackViewController(_ authFallBackViewController: AuthFallBackViewController, didLoginWith loginResponse: MXLoginResponse) { let credentials = MXCredentials(loginResponse: loginResponse, andDefaultCredentials: nil) let client = MXRestClient(credentials: credentials) guard let session = MXSession(matrixRestClient: client) else { - MXLog.error("[AuthenticationCoordinator] authFallBackViewController:didLogin: session could not be created") + MXLog.failure("[AuthenticationCoordinator] authFallBackViewController:didLogin: session could not be created") return } let flow: AuthenticationFlow = initialScreen == .login ? .login : .register - onSessionCreated(session: session, flow: flow) + authenticationType = .other + Task { await onSessionCreated(session: session, flow: flow) } } func authFallBackViewControllerDidClose(_ authFallBackViewController: AuthFallBackViewController) { diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 9571b27bd..060dca366 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -137,8 +137,10 @@ class AuthenticationService: NSObject { func reset() { loginWizard = nil registrationWizard = nil - - self.state = AuthenticationState(flow: .login, homeserverAddress: state.homeserver.address) + + // The previously used homeserver is re-used as `startFlow` will be called again a replace it anyway. + let address = state.homeserver.addressFromUser ?? state.homeserver.address + self.state = AuthenticationState(flow: .login, homeserverAddress: address) } /// Continues an SSO flow when completion comes via a deep link. diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift index 9b1b7afe9..ccc4f4d97 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift @@ -63,18 +63,17 @@ struct AuthenticationState { AuthenticationHomeserverViewData(address: displayableAddress, isMatrixDotOrg: isMatrixDotOrg, showLoginForm: preferredLoginMode.supportsPasswordFlow, - showRegistrationForm: registrationFlow != nil, + showRegistrationForm: registrationFlow != nil && !needsRegistrationFallback, ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? []) } - /// Needs authentication fallback for login or registration - var needsFallback: Bool { - switch preferredLoginMode { - case .unsupported: - return true - default: - break - } + /// Needs authentication fallback for login + var needsLoginFallback: Bool { + return preferredLoginMode.isUnsupported + } + + /// Needs authentication fallback for registration + var needsRegistrationFallback: Bool { guard let flow = registrationFlow else { return false } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift index 33ed39482..eef20e50d 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift @@ -75,6 +75,15 @@ enum LoginMode { return false } } + + var isUnsupported: Bool { + switch self { + case .unsupported: + return true + default: + return false + } + } } /// Data obtained when calling `LoginWizard.resetPassword` that will be used diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index e32c8005f..8f354b957 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -29,7 +29,9 @@ enum AuthenticationLoginCoordinatorResult { /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) /// Login was successful with the associated session created. - case success(session: MXSession, password: String?) + case success(session: MXSession, password: String) + /// Login requested a fallback + case fallback } final class AuthenticationLoginCoordinator: Coordinator, Presentable { @@ -110,7 +112,7 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { case .continueWithSSO(let identityProvider): self.callback?(.continueWithSSO(identityProvider)) case .fallback: - self.showFallback() + self.callback?(.fallback) } } } @@ -238,57 +240,8 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { let homeserver = authenticationService.state.homeserver authenticationLoginViewModel.update(homeserver: homeserver.viewData) - if homeserver.needsFallback { - showFallback() + if homeserver.needsLoginFallback { + callback?(.fallback) } } - - private func showFallback() { - let url = authenticationService.fallbackURL(for: .login) - - MXLog.debug("[AuthenticationLoginCoordinator] showFallback, url: \(url)") - - guard let fallbackVC = AuthFallBackViewController(url: url.absoluteString) else { - MXLog.error("[AuthenticationLoginCoordinator] showFallback: could not create fallback view controller") - return - } - fallbackVC.delegate = self - let navController = RiotNavigationController(rootViewController: fallbackVC) - navController.navigationBar.topItem?.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, - target: self, - action: #selector(dismissFallback)) - navigationRouter.present(navController, animated: true) - } - - @objc - private func dismissFallback() { - MXLog.debug("[AuthenticationLoginCoorrdinator] dismissFallback") - - guard let fallbackNavigationVC = navigationRouter.toPresentable().presentedViewController as? RiotNavigationController else { - return - } - fallbackNavigationVC.dismiss(animated: true) - authenticationService.reset() - } -} - - -// MARK: - AuthFallBackViewControllerDelegate -extension AuthenticationLoginCoordinator: AuthFallBackViewControllerDelegate { - @MainActor func authFallBackViewController(_ authFallBackViewController: AuthFallBackViewController, - didLoginWith loginResponse: MXLoginResponse) { - let credentials = MXCredentials(loginResponse: loginResponse, andDefaultCredentials: nil) - let client = MXRestClient(credentials: credentials) - guard let session = MXSession(matrixRestClient: client) else { - MXLog.error("[AuthenticationCoordinator] authFallBackViewController:didLogin: session could not be created") - return - } - dismissFallback() - callback?(.success(session: session, password: nil)) - } - - func authFallBackViewControllerDidClose(_ authFallBackViewController: AuthFallBackViewController) { - dismissFallback() - } - } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift index 1f184819d..429554e4f 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift @@ -27,6 +27,8 @@ enum AuthenticationRegistrationViewModelResult { case createAccount(username: String, password: String) /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) + /// Continue using a fallback + case fallback } // MARK: View @@ -96,6 +98,8 @@ enum AuthenticationRegistrationViewAction { case next /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) + /// Continue using the fallback page + case fallback } enum AuthenticationRegistrationErrorType: Hashable { diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift index d88d9b470..73ec90f16 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift @@ -54,6 +54,8 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy Task { await callback?(.createAccount(username: state.bindings.username, password: state.bindings.password)) } case .continueWithSSO(let provider): Task { await callback?(.continueWithSSO(provider)) } + case .fallback: + Task { await callback?(.fallback) } } } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index e14fb538a..55afde0f4 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -32,6 +32,8 @@ enum AuthenticationRegistrationCoordinatorResult { case continueWithSSO(SSOIdentityProvider) /// The screen completed with the associated registration result. case completed(result: RegistrationResult, password: String) + /// Continue using the fallback + case fallback } final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { @@ -109,6 +111,8 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { self.createAccount(username: username, password: password) case .continueWithSSO(let provider): self.callback?(.continueWithSSO(provider)) + case .fallback: + self.callback?(.fallback) } } } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift index 561fda0b8..2c0c280ef 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift @@ -28,6 +28,7 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable { case passwordWithCredentials case passwordWithUsernameError case ssoOnly + case fallback /// The associated screen var screenType: Any.Type { @@ -52,6 +53,8 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable { Task { await viewModel.displayError(.usernameUnavailable(VectorL10n.authInvalidUserName)) } case .ssoOnly: viewModel = AuthenticationRegistrationViewModel(homeserver: .mockEnterpriseSSO) + case .fallback: + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockFallback) } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift index c21206728..3725aa0ab 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift @@ -34,12 +34,14 @@ class AuthenticationRegistrationUITests: MockScreenTest { let state = "matrix.org" validateRegistrationFormIsVisible(for: state) validateSSOButtonsAreShown(for: state) + validateFallbackButtonIsHidden(for: state) validateNoErrorsAreShown(for: state) case .passwordOnly: let state = "a password only server" validateRegistrationFormIsVisible(for: state) validateSSOButtonsAreHidden(for: state) + validateFallbackButtonIsHidden(for: state) validateNextButtonIsDisabled(for: state) @@ -48,6 +50,7 @@ class AuthenticationRegistrationUITests: MockScreenTest { let state = "a password only server with credentials entered" validateRegistrationFormIsVisible(for: state) validateSSOButtonsAreHidden(for: state) + validateFallbackButtonIsHidden(for: state) validateNextButtonIsEnabled(for: state) @@ -56,6 +59,7 @@ class AuthenticationRegistrationUITests: MockScreenTest { let state = "a password only server with an invalid username" validateRegistrationFormIsVisible(for: state) validateSSOButtonsAreHidden(for: state) + validateFallbackButtonIsHidden(for: state) validateNextButtonIsDisabled(for: state) @@ -64,6 +68,12 @@ class AuthenticationRegistrationUITests: MockScreenTest { let state = "an SSO only server" validateRegistrationFormIsHidden(for: state) validateSSOButtonsAreShown(for: state) + validateFallbackButtonIsHidden(for: state) + case .fallback: + let state = "fallback" + validateRegistrationFormIsHidden(for: state) + validateSSOButtonsAreHidden(for: state) + validateFallbackButtonIsShown(for: state) } } @@ -88,6 +98,21 @@ class AuthenticationRegistrationUITests: MockScreenTest { XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).") XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).") } + + /// Checks that the fallback button is hidden. + func validateFallbackButtonIsHidden(for state: String) { + let fallbackButton = app.buttons["fallbackButton"] + + XCTAssertFalse(fallbackButton.exists, "The fallback button should not be shown for \(state).") + } + + /// Checks that the fallback button is hidden. + func validateFallbackButtonIsShown(for state: String) { + let fallbackButton = app.buttons["fallbackButton"] + + XCTAssertTrue(fallbackButton.exists, "The fallback button should be shown for \(state).") + XCTAssertTrue(fallbackButton.isEnabled, "The fallback button should be enabled for \(state).") + } /// Checks that there is at least one SSO button shown on the screen. func validateSSOButtonsAreShown(for state: String) { diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift index 2724495b6..d6565b2f8 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift @@ -49,6 +49,18 @@ import Combine XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should should match the new homeserver.") XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.") } + + func testFallbackServer() { + // Given a basic server example.com that only supports password registration. + let homeserver = AuthenticationHomeserverViewData.mockFallback + + // When updating the view model with the server. + viewModel.update(homeserver: homeserver) + + // Then the view state should be updated with the homeserver and hide the SSO buttons and registration form. + XCTAssertFalse(context.viewState.homeserver.showRegistrationForm, "The registration form should not be shown.") + XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.") + } func testUsernameError() async throws { // Given a form with a valid username. diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift index 60bff52a4..8ebed8d86 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -59,6 +59,10 @@ struct AuthenticationRegistrationScreen: View { ssoButtons .padding(.top, 16) } + + if !viewModel.viewState.homeserver.showRegistrationForm && !viewModel.viewState.showSSOButtons { + fallbackButton + } } .readableFrame() @@ -143,6 +147,15 @@ struct AuthenticationRegistrationScreen: View { } } } + + /// A fallback button that can be used for login. + var fallbackButton: some View { + Button(action: fallback) { + Text(VectorL10n.authRegister) + } + .buttonStyle(PrimaryActionButtonStyle()) + .accessibilityIdentifier("fallbackButton") + } /// Validates the username when the text field ends editing, and selects the password text field. func usernameEditingChanged(isEditing: Bool) { @@ -167,6 +180,11 @@ struct AuthenticationRegistrationScreen: View { guard viewModel.viewState.hasValidCredentials else { return } viewModel.send(viewAction: .next) } + + /// Sends the `fallback` view action. + func fallback() { + viewModel.send(viewAction: .fallback) + } } // MARK: - Previews From 8819ab65f642dba05970c9f13c3b0dc0c0a4d7bc Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Tue, 31 May 2022 11:01:17 +0300 Subject: [PATCH 20/30] Use flow in the state --- Riot/Modules/Onboarding/AuthenticationCoordinator.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index 42f4e9446..2ac31a659 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -668,9 +668,8 @@ extension AuthenticationCoordinator: AuthFallBackViewControllerDelegate { MXLog.failure("[AuthenticationCoordinator] authFallBackViewController:didLogin: session could not be created") return } - let flow: AuthenticationFlow = initialScreen == .login ? .login : .register authenticationType = .other - Task { await onSessionCreated(session: session, flow: flow) } + Task { await onSessionCreated(session: session, flow: authenticationService.state.flow) } } func authFallBackViewControllerDidClose(_ authFallBackViewController: AuthFallBackViewController) { From 20b3a30677e670beec74b6c9448b5ce06205875a Mon Sep 17 00:00:00 2001 From: wtimme Date: Wed, 25 May 2022 16:01:50 +0200 Subject: [PATCH 21/30] Add changelog entry for #6196 --- changelog.d/pr-6196.build | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/pr-6196.build diff --git a/changelog.d/pr-6196.build b/changelog.d/pr-6196.build new file mode 100644 index 000000000..00ba642a6 --- /dev/null +++ b/changelog.d/pr-6196.build @@ -0,0 +1 @@ +Ensure that warnings from CocoaPods dependencies do not show up in Xcode From ee5203aea34cd6d3a671e8faa8181f6f5a3d4711 Mon Sep 17 00:00:00 2001 From: wtimme Date: Tue, 31 May 2022 10:50:28 +0200 Subject: [PATCH 22/30] Let warnings from MatrixSDK and AnalyticsEvents show up in Xcode As requested by @pixlwave during their review of #6196, these `inhibit_warnings` directive configure CocoaPods in a way that the warnings for both MatrixSDK and AnalyticsEvents show up when building the workspace in Xcode. --- Podfile | 6 +++--- Podfile.lock | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Podfile b/Podfile index 52b3a2866..6d13ad7b1 100644 --- a/Podfile +++ b/Podfile @@ -45,8 +45,8 @@ end # Method to import the MatrixSDK def import_MatrixSDK - pod 'MatrixSDK', $matrixSDKVersionSpec - pod 'MatrixSDK/JingleCallStack', $matrixSDKVersionSpec + pod 'MatrixSDK', $matrixSDKVersionSpec, :inhibit_warnings => false + pod 'MatrixSDK/JingleCallStack', $matrixSDKVersionSpec, :inhibit_warnings => false end ######################################## @@ -72,7 +72,7 @@ abstract_target 'RiotPods' do # PostHog for analytics pod 'PostHog', '~> 1.4.4' - pod 'AnalyticsEvents', :git => 'https://github.com/matrix-org/matrix-analytics-events.git', :branch => 'release/swift' + pod 'AnalyticsEvents', :git => 'https://github.com/matrix-org/matrix-analytics-events.git', :branch => 'release/swift', :inhibit_warnings => false # pod 'AnalyticsEvents', :path => '../matrix-analytics-events/AnalyticsEvents.podspec' pod 'OLMKit' diff --git a/Podfile.lock b/Podfile.lock index 77a15243d..80f47bdd0 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -229,6 +229,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: ae5f22f20d68ccfcf8fc5693696ccbeaf55a7d34 +PODFILE CHECKSUM: 58eaf3c43ff9aa421acb52c5340537c6b9403c6e COCOAPODS: 1.11.3 From 5a18589ec50d61f46cbe8c9145d082318e436f00 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 31 May 2022 14:04:08 +0100 Subject: [PATCH 23/30] Prepare for new sprint --- Config/AppVersion.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 0593af953..9636ce332 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -15,5 +15,5 @@ // // Version -MARKETING_VERSION = 1.8.17 -CURRENT_PROJECT_VERSION = 1.8.17 +MARKETING_VERSION = 1.8.18 +CURRENT_PROJECT_VERSION = 1.8.18 From d7216cf17171ef4db51700e150cadfb91dbe6f1d Mon Sep 17 00:00:00 2001 From: aringenbach Date: Mon, 30 May 2022 14:32:58 +0200 Subject: [PATCH 24/30] WellKnown: support outbound keys presharing strategy --- Riot/Categories/MXPreSharingStrategy.swift | 34 +++++++++++++++++++ .../HomeserverConfigurationBuilder.swift | 5 ++- .../HomeserverEncryptionConfiguration.swift | 5 ++- Riot/Model/WellKnown/VectorWellKnown.swift | 5 +++ Riot/Modules/Application/LegacyAppDelegate.m | 5 +++ RiotTests/HomeserverConfigurationTests.swift | 11 ++++-- changelog.d/6214.change | 1 + 7 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 Riot/Categories/MXPreSharingStrategy.swift create mode 100644 changelog.d/6214.change diff --git a/Riot/Categories/MXPreSharingStrategy.swift b/Riot/Categories/MXPreSharingStrategy.swift new file mode 100644 index 000000000..1503234e0 --- /dev/null +++ b/Riot/Categories/MXPreSharingStrategy.swift @@ -0,0 +1,34 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +extension MXKKeyPreSharingStrategy { + init?(key: String?) { + guard let key = key else { + return nil + } + + switch key { + case "on_typing": + self = .whenTyping + case "on_room_opening": + self = .whenEnteringRoom + default: + self = .none + } + } +} diff --git a/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift b/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift index 54bf0dcf6..5dcb042bc 100644 --- a/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift +++ b/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift @@ -41,6 +41,8 @@ final class HomeserverConfigurationBuilder: NSObject { let isE2EEByDefaultEnabled = vectorWellKnownEncryptionConfiguration?.isE2EEByDefaultEnabled ?? true // Disable mandatory secure backup when there is no value let isSecureBackupRequired = vectorWellKnownEncryptionConfiguration?.isSecureBackupRequired ?? false + // Default to `MXKKeyPreSharingWhenTyping` when there is no value + let outboundKeysPreSharingMode = vectorWellKnownEncryptionConfiguration?.outboundKeysPreSharingMode ?? .whenTyping // Defaults to all secure backup methods available when there is no value let secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod] if let backupSetupMethods = vectorWellKnownEncryptionConfiguration?.secureBackupSetupMethods { @@ -51,7 +53,8 @@ final class HomeserverConfigurationBuilder: NSObject { let encryptionConfiguration = HomeserverEncryptionConfiguration(isE2EEByDefaultEnabled: isE2EEByDefaultEnabled, isSecureBackupRequired: isSecureBackupRequired, - secureBackupSetupMethods: secureBackupSetupMethods) + secureBackupSetupMethods: secureBackupSetupMethods, + outboundKeysPreSharingMode: outboundKeysPreSharingMode) // Jitsi configuration let jitsiPreferredDomain: String? diff --git a/Riot/Model/HomeserverConfiguration/HomeserverEncryptionConfiguration.swift b/Riot/Model/HomeserverConfiguration/HomeserverEncryptionConfiguration.swift index 19b9aaee1..6672bff45 100644 --- a/Riot/Model/HomeserverConfiguration/HomeserverEncryptionConfiguration.swift +++ b/Riot/Model/HomeserverConfiguration/HomeserverEncryptionConfiguration.swift @@ -22,12 +22,15 @@ final class HomeserverEncryptionConfiguration: NSObject { let isE2EEByDefaultEnabled: Bool let isSecureBackupRequired: Bool let secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod] + let outboundKeysPreSharingMode: MXKKeyPreSharingStrategy init(isE2EEByDefaultEnabled: Bool, isSecureBackupRequired: Bool, - secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]) { + secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod], + outboundKeysPreSharingMode: MXKKeyPreSharingStrategy) { self.isE2EEByDefaultEnabled = isE2EEByDefaultEnabled self.isSecureBackupRequired = isSecureBackupRequired + self.outboundKeysPreSharingMode = outboundKeysPreSharingMode self.secureBackupSetupMethods = secureBackupSetupMethods super.init() diff --git a/Riot/Model/WellKnown/VectorWellKnown.swift b/Riot/Model/WellKnown/VectorWellKnown.swift index 8d5669b21..71c127e89 100644 --- a/Riot/Model/WellKnown/VectorWellKnown.swift +++ b/Riot/Model/WellKnown/VectorWellKnown.swift @@ -48,6 +48,8 @@ struct VectorWellKnownEncryptionConfiguration { let isSecureBackupRequired: Bool? /// Methods to use to setup secure backup (SSSS). let secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]? + /// Outbound keys pre sharing strategy. + let outboundKeysPreSharingMode: MXKKeyPreSharingStrategy? } extension VectorWellKnownEncryptionConfiguration: Decodable { @@ -56,6 +58,7 @@ extension VectorWellKnownEncryptionConfiguration: Decodable { case isE2EEByDefaultEnabled = "default" case isSecureBackupRequired = "secure_backup_required" case secureBackupSetupMethods = "secure_backup_setup_methods" + case outboundKeysPreSharingMode = "outbound_keys_pre_sharing_mode" } init(from decoder: Decoder) throws { @@ -64,6 +67,8 @@ extension VectorWellKnownEncryptionConfiguration: Decodable { isSecureBackupRequired = try? container.decode(Bool.self, forKey: .isSecureBackupRequired) let secureBackupSetupMethodsKeys = try? container.decode([String].self, forKey: .secureBackupSetupMethods) secureBackupSetupMethods = secureBackupSetupMethodsKeys?.compactMap { VectorWellKnownBackupSetupMethod(key: $0) } + let outboundKeysPreSharingModeKey = try? container.decode(String.self, forKey: .outboundKeysPreSharingMode) + outboundKeysPreSharingMode = MXKKeyPreSharingStrategy(key: outboundKeysPreSharingModeKey) } } diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index d51df3891..36745b586 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -2430,6 +2430,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } BOOL isLaunching = NO; + + if (mainSession.vc_homeserverConfiguration) + { + [MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy = mainSession.vc_homeserverConfiguration.encryption.outboundKeysPreSharingMode; + } if (_masterTabBarController.isOnboardingInProgress) { diff --git a/RiotTests/HomeserverConfigurationTests.swift b/RiotTests/HomeserverConfigurationTests.swift index ec8c8c161..24cc50cd7 100644 --- a/RiotTests/HomeserverConfigurationTests.swift +++ b/RiotTests/HomeserverConfigurationTests.swift @@ -41,7 +41,9 @@ class HomeserverConfigurationTests: XCTestCase { let expectedSecureBackupRequired = true let secureBackupSetupMethods = ["passphrase"] let expectedSecureBackupSetupMethods: [VectorWellKnownBackupSetupMethod] = [.passphrase] - + let outboundKeysPreSharingMode = "on_room_opening" + let expectedOutboundKeysPreSharingMode: MXKKeyPreSharingStrategy = .whenEnteringRoom + let wellKnownDictionary: [String: Any] = [ "m.homeserver": [ "base_url": "https://your.homeserver.org" @@ -61,7 +63,8 @@ class HomeserverConfigurationTests: XCTestCase { "io.element.e2ee" : [ "default" : expectedE2EEEByDefaultEnabled, "secure_backup_required": expectedSecureBackupRequired, - "secure_backup_setup_methods": secureBackupSetupMethods + "secure_backup_setup_methods": secureBackupSetupMethods, + "outbound_keys_pre_sharing_mode": outboundKeysPreSharingMode ], "io.element.jitsi" : [ "preferredDomain" : expectedJitsiServer @@ -78,6 +81,8 @@ class HomeserverConfigurationTests: XCTestCase { XCTAssertEqual(homeserverConfiguration.encryption.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled) XCTAssertEqual(homeserverConfiguration.encryption.isSecureBackupRequired, expectedSecureBackupRequired) XCTAssertEqual(homeserverConfiguration.encryption.secureBackupSetupMethods, expectedSecureBackupSetupMethods) + XCTAssertEqual(homeserverConfiguration.encryption.outboundKeysPreSharingMode, expectedOutboundKeysPreSharingMode) + XCTAssertEqual(homeserverConfiguration.tileServer.mapStyleURL.absoluteString, expectedMapStyleURLString) } @@ -86,6 +91,7 @@ class HomeserverConfigurationTests: XCTestCase { let expectedE2EEEByDefaultEnabled = true let expectedSecureBackupRequired = false let expectedSecureBackupSetupMethods: [VectorWellKnownBackupSetupMethod] = [.passphrase, .key] + let expectedOutboundKeysPreSharingMode: MXKKeyPreSharingStrategy = .whenTyping let wellKnownDictionary: [String: Any] = [ "m.homeserver": [ @@ -104,5 +110,6 @@ class HomeserverConfigurationTests: XCTestCase { XCTAssertEqual(homeserverConfiguration.encryption.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled) XCTAssertEqual(homeserverConfiguration.encryption.isSecureBackupRequired, expectedSecureBackupRequired) XCTAssertEqual(homeserverConfiguration.encryption.secureBackupSetupMethods, expectedSecureBackupSetupMethods) + XCTAssertEqual(homeserverConfiguration.encryption.outboundKeysPreSharingMode, expectedOutboundKeysPreSharingMode) } } diff --git a/changelog.d/6214.change b/changelog.d/6214.change new file mode 100644 index 000000000..0a99ba8d9 --- /dev/null +++ b/changelog.d/6214.change @@ -0,0 +1 @@ +WellKnown: support outbound keys presharing strategy From 5ba8a110f958f38b2f4ce37a9e189cec2ec197b1 Mon Sep 17 00:00:00 2001 From: MaximeE Date: Wed, 1 Jun 2022 14:17:27 +0200 Subject: [PATCH 25/30] 6202: Add pan gesture recognizer to handle detection of user moving across map --- .../View/LocationSharingMapView.swift | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift index 97836ef64..cc6c80245 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift @@ -65,6 +65,9 @@ struct LocationSharingMapView: UIViewRepresentable { let mapView = self.makeMapView() mapView.delegate = context.coordinator + let panGesture = UIPanGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.didPan)) + panGesture.delegate = context.coordinator + mapView.addGestureRecognizer(panGesture) return mapView } @@ -106,11 +109,12 @@ struct LocationSharingMapView: UIViewRepresentable { @available(iOS 14, *) extension LocationSharingMapView { - class Coordinator: NSObject, MGLMapViewDelegate { + class Coordinator: NSObject, MGLMapViewDelegate, UIGestureRecognizerDelegate { // MARK: - Properties var locationSharingMapView: LocationSharingMapView + var mapCenterCoordinate: CLLocationCoordinate2D? // MARK: - Setup @@ -158,13 +162,7 @@ extension LocationSharingMapView { } func mapView(_ mapView: MGLMapView, regionDidChangeAnimated animated: Bool) { - let mapCenterCoordinate = mapView.centerCoordinate - // Prevent this function to set pinLocation when the map is openning - guard let userLocation = locationSharingMapView.userLocation, - !userLocation.isEqual(to: mapCenterCoordinate, precision: 0.0000000001) else { - return - } - locationSharingMapView.mapCenterCoordinate = mapCenterCoordinate + self.mapCenterCoordinate = mapView.centerCoordinate } // MARK: Callout @@ -182,11 +180,24 @@ extension LocationSharingMapView { } func mapView(_ mapView: MGLMapView, tapOnCalloutFor annotation: MGLAnnotation) { - locationSharingMapView.onCalloutTap?(annotation) // Hide the callout mapView.deselectAnnotation(annotation, animated: true) } + + // MARK: UIGestureRecognizer + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return gestureRecognizer is UIPanGestureRecognizer + } + + @objc + func didPan() { + guard let mapCenterCoordinate = mapCenterCoordinate else { + return + } + locationSharingMapView.mapCenterCoordinate = mapCenterCoordinate + } } } From a161453d3fa2985a0b5a3b017b471cf5a15895d1 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 1 Jun 2022 16:50:15 +0100 Subject: [PATCH 26/30] Remove spaces issues to delight board automation (#6230) * Remove spaces issues to delight board automation * Move issues with the Team Delight label * Fix label name --- .github/workflows/triage-move-labelled.yml | 53 ++++++++++---------- .github/workflows/triage-priority-bugs.yml | 57 ++++++++++------------ 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml index 994a13941..e5799dc61 100644 --- a/.github/workflows/triage-move-labelled.yml +++ b/.github/workflows/triage-move-labelled.yml @@ -9,15 +9,15 @@ jobs: name: Add Z-Labs label for features behind labs flags runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'A-Maths') || - contains(github.event.issue.labels.*.name, 'A-Message-Pinning') || - contains(github.event.issue.labels.*.name, 'A-Polls') || - contains(github.event.issue.labels.*.name, 'A-Location-Sharing') || - contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') || - contains(github.event.issue.labels.*.name, 'Z-IA') || - contains(github.event.issue.labels.*.name, 'A-Themes-Custom') || - contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') || - contains(github.event.issue.labels.*.name, 'A-Tags') + contains(github.event.issue.labels.*.name, 'A-Maths') || + contains(github.event.issue.labels.*.name, 'A-Message-Pinning') || + contains(github.event.issue.labels.*.name, 'A-Polls') || + contains(github.event.issue.labels.*.name, 'A-Location-Sharing') || + contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') || + contains(github.event.issue.labels.*.name, 'Z-IA') || + contains(github.event.issue.labels.*.name, 'A-Themes-Custom') || + contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') || + contains(github.event.issue.labels.*.name, 'A-Tags') steps: - uses: actions/github-script@v5 with: @@ -44,14 +44,14 @@ jobs: name: P1 X-Needs-Design to Design project board runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'X-Needs-Design') && - (contains(github.event.issue.labels.*.name, 'S-Critical') && - (contains(github.event.issue.labels.*.name, 'O-Frequent') || - contains(github.event.issue.labels.*.name, 'O-Occasional')) || - contains(github.event.issue.labels.*.name, 'S-Major') && - contains(github.event.issue.labels.*.name, 'O-Frequent') || - contains(github.event.issue.labels.*.name, 'A11y') && - contains(github.event.issue.labels.*.name, 'O-Frequent')) + contains(github.event.issue.labels.*.name, 'X-Needs-Design') && + (contains(github.event.issue.labels.*.name, 'S-Critical') && + (contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'O-Occasional')) || + contains(github.event.issue.labels.*.name, 'S-Major') && + contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'A11y') && + contains(github.event.issue.labels.*.name, 'O-Frequent')) steps: - uses: octokit/graphql-action@v2.x id: add_to_project @@ -75,7 +75,7 @@ jobs: name: X-Needs-Product to Design project board runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'X-Needs-Product') + contains(github.event.issue.labels.*.name, 'X-Needs-Product') steps: - uses: octokit/graphql-action@v2.x id: add_to_project @@ -99,10 +99,7 @@ jobs: name: Spaces issues to Delight project board runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'A-Spaces') || - contains(github.event.issue.labels.*.name, 'A-Space-Settings') || - contains(github.event.issue.labels.*.name, 'A-Subspaces') || - contains(github.event.issue.labels.*.name, 'Z-IA') + contains(github.event.issue.labels.*.name, 'Team: Delight') steps: - uses: octokit/graphql-action@v2.x with: @@ -125,7 +122,7 @@ jobs: name: A-Voice Messages to voice message board runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'A-Voice Messages') + contains(github.event.issue.labels.*.name, 'A-Voice Messages') steps: - uses: octokit/graphql-action@v2.x with: @@ -148,7 +145,7 @@ jobs: name: A-Threads to Thread board runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'A-Threads') + contains(github.event.issue.labels.*.name, 'A-Threads') steps: - uses: octokit/graphql-action@v2.x with: @@ -171,7 +168,7 @@ jobs: name: A-Message-Bubbles to Message bubble board runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') + contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') steps: - uses: octokit/graphql-action@v2.x with: @@ -194,7 +191,7 @@ jobs: name: Z-FTUE to FTUE board runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'Z-FTUE') + contains(github.event.issue.labels.*.name, 'Z-FTUE') steps: - uses: octokit/graphql-action@v2.x with: @@ -212,12 +209,12 @@ jobs: env: PROJECT_ID: "PN_kwDOAM0swc4AAqVx" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - + move_WTF_issues: name: Z-WTF to WTF board runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'Z-WTF') + contains(github.event.issue.labels.*.name, 'Z-WTF') steps: - uses: octokit/graphql-action@v2.x with: diff --git a/.github/workflows/triage-priority-bugs.yml b/.github/workflows/triage-priority-bugs.yml index 843c6234c..5226d16e8 100644 --- a/.github/workflows/triage-priority-bugs.yml +++ b/.github/workflows/triage-priority-bugs.yml @@ -8,22 +8,19 @@ jobs: p1_issues_to_team_workboard: runs-on: ubuntu-latest if: > - (!contains(github.event.issue.labels.*.name, 'A-E2EE') && - !contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') && - !contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') && - !contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') && - !contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification') && - !contains(github.event.issue.labels.*.name, 'A-Spaces') && - !contains(github.event.issue.labels.*.name, 'A-Spaces-Settings') && - !contains(github.event.issue.labels.*.name, 'A-Subspaces')) && - (contains(github.event.issue.labels.*.name, 'T-Defect') && - contains(github.event.issue.labels.*.name, 'S-Critical') && - (contains(github.event.issue.labels.*.name, 'O-Frequent') || - contains(github.event.issue.labels.*.name, 'O-Occasional')) || - contains(github.event.issue.labels.*.name, 'S-Major') && - contains(github.event.issue.labels.*.name, 'O-Frequent') || - contains(github.event.issue.labels.*.name, 'A11y') && - contains(github.event.issue.labels.*.name, 'O-Frequent')) + (!contains(github.event.issue.labels.*.name, 'A-E2EE') && + !contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') && + !contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') && + !contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') && + !contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification') && + (contains(github.event.issue.labels.*.name, 'T-Defect') && + contains(github.event.issue.labels.*.name, 'S-Critical') && + (contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'O-Occasional')) || + contains(github.event.issue.labels.*.name, 'S-Major') && + contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'A11y') && + contains(github.event.issue.labels.*.name, 'O-Frequent')) steps: - uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488 with: @@ -34,20 +31,20 @@ jobs: P1_issues_to_crypto_team_workboard: runs-on: ubuntu-latest if: > - contains(github.event.issue.labels.*.name, 'Z-UISI') || - (contains(github.event.issue.labels.*.name, 'A-E2EE') || - contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') || - contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') || - contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') || - contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification')) && - (contains(github.event.issue.labels.*.name, 'T-Defect') && - contains(github.event.issue.labels.*.name, 'S-Critical') && - (contains(github.event.issue.labels.*.name, 'O-Frequent') || - contains(github.event.issue.labels.*.name, 'O-Occasional')) || - contains(github.event.issue.labels.*.name, 'S-Major') && - contains(github.event.issue.labels.*.name, 'O-Frequent') || - contains(github.event.issue.labels.*.name, 'A11y') && - contains(github.event.issue.labels.*.name, 'O-Frequent')) + contains(github.event.issue.labels.*.name, 'Z-UISI') || + (contains(github.event.issue.labels.*.name, 'A-E2EE') || + contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') || + contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') || + contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') || + contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification')) && + (contains(github.event.issue.labels.*.name, 'T-Defect') && + contains(github.event.issue.labels.*.name, 'S-Critical') && + (contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'O-Occasional')) || + contains(github.event.issue.labels.*.name, 'S-Major') && + contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'A11y') && + contains(github.event.issue.labels.*.name, 'O-Frequent')) steps: - uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488 with: From 0618cc4aa372f87f57e1a719ed1c551a4364285a Mon Sep 17 00:00:00 2001 From: MaximeE Date: Thu, 2 Jun 2022 11:34:01 +0200 Subject: [PATCH 27/30] 6202: Improve how pan gesture is handled --- .../LocationSharing/LocationSharingModels.swift | 5 ++--- .../LocationSharingViewModel.swift | 6 +++++- .../View/LocationSharingMapView.swift | 15 +++++++-------- .../View/LocationSharingView.swift | 5 ++++- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift index b4ade5e31..ec9eb58bd 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift @@ -39,6 +39,7 @@ enum LocationSharingViewAction { case goToUserLocation case startLiveSharing case shareLiveLocation(timeout: LiveLocationSharingTimeout) + case userDidPan } enum LocationSharingViewModelResult { @@ -70,9 +71,7 @@ struct LocationSharingViewState: BindableState { var highlightedAnnotation: LocationAnnotation? /// Indicates whether the user has moved around the map to drop a pin somewhere other than their current location - var isPinDropSharing: Bool { - return bindings.pinLocation != nil - } + var isPinDropSharing: Bool = false var showLoadingIndicator: Bool = false diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift index 78a38040b..e2b64ff85 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift @@ -78,12 +78,16 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie completion?(.share(latitude: pinLocation.latitude, longitude: pinLocation.longitude, coordinateType: .pin)) case .goToUserLocation: - state.bindings.pinLocation = nil + state.showsUserLocation = true + state.isPinDropSharing = false case .startLiveSharing: self.startLiveLocationSharing() case .shareLiveLocation(let timeout): state.bindings.showingTimerSelector = false completion?(.shareLiveLocation(timeout: timeout.rawValue)) + case .userDidPan: + state.showsUserLocation = false + state.isPinDropSharing = true } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift index cc6c80245..8ad3ccceb 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift @@ -58,6 +58,9 @@ struct LocationSharingMapView: UIViewRepresentable { /// Publish view errors if any let errorSubject: PassthroughSubject + + /// Called when the user pan on the map + var userDidPan: (() -> Void)? // MARK: - UIViewRepresentable @@ -80,7 +83,7 @@ struct LocationSharingMapView: UIViewRepresentable { mapView.setCenter(highlightedAnnotation.coordinate, zoomLevel: Constants.mapZoomLevel, animated: false) } - if self.showsUserLocation && mapCenterCoordinate == nil { + if self.showsUserLocation { mapView.showsUserLocation = true mapView.userTrackingMode = .follow } else { @@ -114,7 +117,6 @@ extension LocationSharingMapView { // MARK: - Properties var locationSharingMapView: LocationSharingMapView - var mapCenterCoordinate: CLLocationCoordinate2D? // MARK: - Setup @@ -130,7 +132,7 @@ extension LocationSharingMapView { return LocationAnnotationView(userLocationAnnotation: userLocationAnnotation) } else if let pinLocationAnnotation = annotation as? PinLocationAnnotation { return LocationAnnotationView(pinLocationAnnotation: pinLocationAnnotation) - } else if annotation is MGLUserLocation && locationSharingMapView.mapCenterCoordinate == nil, let currentUserAvatarData = locationSharingMapView.userAvatarData { + } else if annotation is MGLUserLocation, let currentUserAvatarData = locationSharingMapView.userAvatarData { // Replace default current location annotation view with a UserLocationAnnotatonView when the map is center on user location return LocationAnnotationView(avatarData: currentUserAvatarData) } @@ -162,7 +164,7 @@ extension LocationSharingMapView { } func mapView(_ mapView: MGLMapView, regionDidChangeAnimated animated: Bool) { - self.mapCenterCoordinate = mapView.centerCoordinate + locationSharingMapView.mapCenterCoordinate = mapView.centerCoordinate } // MARK: Callout @@ -193,10 +195,7 @@ extension LocationSharingMapView { @objc func didPan() { - guard let mapCenterCoordinate = mapCenterCoordinate else { - return - } - locationSharingMapView.mapCenterCoordinate = mapCenterCoordinate + locationSharingMapView.userDidPan?() } } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift index 52ec07f22..c986f5a6a 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift @@ -76,7 +76,10 @@ struct LocationSharingView: View { showsUserLocation: context.viewState.showsUserLocation, userLocation: $context.userLocation, mapCenterCoordinate: $context.pinLocation, - errorSubject: context.viewState.errorSubject) + errorSubject: context.viewState.errorSubject, + userDidPan: { + context.send(viewAction: .userDidPan) + }) if context.viewState.isPinDropSharing { LocationSharingMarkerView(backgroundColor: theme.colors.accent) { Image(uiImage: Asset.Images.locationPinIcon.image) From 84c36a449e99be12272d1be1ff77603d43741d66 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Fri, 3 Jun 2022 09:46:34 +0200 Subject: [PATCH 28/30] Show user indicators when paginating a room (#6234) - implemented --- Riot/Modules/Room/MXKRoomViewController.m | 16 ++++++++++++++++ changelog.d/5746.change | 1 + 2 files changed, 17 insertions(+) create mode 100644 changelog.d/5746.change diff --git a/Riot/Modules/Room/MXKRoomViewController.m b/Riot/Modules/Room/MXKRoomViewController.m index a8d79f084..db003818e 100644 --- a/Riot/Modules/Room/MXKRoomViewController.m +++ b/Riot/Modules/Room/MXKRoomViewController.m @@ -1903,6 +1903,11 @@ return; } + __block UserIndicatorCancel cancelIndicator; + NSTimer *indicatorTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:NO block:^(NSTimer * _Nonnull timer) { + cancelIndicator = [self.userIndicatorStore presentLoadingWithLabel:[VectorL10n homeSyncing] isInteractionBlocking:NO]; + }]; + // Store the current height of the first bubble (if any) backPaginationSavedFirstBubbleHeight = 0; if (direction == MXTimelineDirectionBackwards && [roomDataSource tableView:_bubblesTableView numberOfRowsInSection:0]) @@ -1987,6 +1992,12 @@ { [self updateCurrentEventIdAtTableBottom:NO]; } + + [indicatorTimer invalidate]; + + if (cancelIndicator) { + cancelIndicator(); + } } failure:^(NSError *error) { @@ -2002,6 +2013,11 @@ self.bubbleTableViewDisplayInTransition = NO; + [indicatorTimer invalidate]; + + if (cancelIndicator) { + cancelIndicator(); + } }]; } diff --git a/changelog.d/5746.change b/changelog.d/5746.change new file mode 100644 index 000000000..c7a5f64b2 --- /dev/null +++ b/changelog.d/5746.change @@ -0,0 +1 @@ +Show user indicators when paginating a room From 9e1884eff727241ba0bf73025eb38ec5091f3873 Mon Sep 17 00:00:00 2001 From: gulekismail Date: Fri, 3 Jun 2022 13:24:15 +0300 Subject: [PATCH 29/30] changelog.d: Upgrade MatrixSDK version ([v0.23.8](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.23.8)). --- Podfile | 2 +- changelog.d/x-nolink-0.change | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/x-nolink-0.change diff --git a/Podfile b/Podfile index 80e363cc5..040fc0b69 100644 --- a/Podfile +++ b/Podfile @@ -16,7 +16,7 @@ use_frameworks! # - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI # # Warning: our internal tooling depends on the name of this variable name, so be sure not to change it -$matrixSDKVersion = '= 0.23.7' +$matrixSDKVersion = '= 0.23.8' # $matrixSDKVersion = :local # $matrixSDKVersion = { :branch => 'develop'} # $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } } diff --git a/changelog.d/x-nolink-0.change b/changelog.d/x-nolink-0.change new file mode 100644 index 000000000..75d07d225 --- /dev/null +++ b/changelog.d/x-nolink-0.change @@ -0,0 +1 @@ +Upgrade MatrixSDK version ([v0.23.8](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.23.8)). \ No newline at end of file From a53602a2f549349c2dd6fcb2e38f23f967daabca Mon Sep 17 00:00:00 2001 From: gulekismail Date: Fri, 3 Jun 2022 13:24:16 +0300 Subject: [PATCH 30/30] version++ --- CHANGES.md | 23 +++++++++++++++++++++++ changelog.d/5654.wip | 1 - changelog.d/5746.change | 1 - changelog.d/6176.change | 1 - changelog.d/6202.bugfix | 1 - changelog.d/6214.change | 1 - changelog.d/pr-6196.build | 1 - changelog.d/pr-6204.build | 1 - changelog.d/x-nolink-0.change | 1 - 9 files changed, 23 insertions(+), 8 deletions(-) delete mode 100644 changelog.d/5654.wip delete mode 100644 changelog.d/5746.change delete mode 100644 changelog.d/6176.change delete mode 100644 changelog.d/6202.bugfix delete mode 100644 changelog.d/6214.change delete mode 100644 changelog.d/pr-6196.build delete mode 100644 changelog.d/pr-6204.build delete mode 100644 changelog.d/x-nolink-0.change diff --git a/CHANGES.md b/CHANGES.md index 9056b686c..aa5b4a0b8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,26 @@ +## Changes in 1.8.18 (2022-06-03) + +🙌 Improvements + +- Upgrade MatrixSDK version ([v0.23.8](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.23.8)). +- Show user indicators when paginating a room ([#5746](https://github.com/vector-im/element-ios/issues/5746)) +- Authentication: Display fallback screens on registration & login according to the HS needs. ([#6176](https://github.com/vector-im/element-ios/issues/6176)) +- WellKnown: support outbound keys presharing strategy ([#6214](https://github.com/vector-im/element-ios/issues/6214)) + +🐛 Bugfixes + +- Location sharing: Improve automatic detection of pin drop state ([#6202](https://github.com/vector-im/element-ios/issues/6202)) + +🧱 Build + +- Ensure that warnings from CocoaPods dependencies do not show up in Xcode ([#6196](https://github.com/vector-im/element-ios/pull/6196)) +- CI: Use macOS 12 and Xcode 13.4 ([#6204](https://github.com/vector-im/element-ios/pull/6204)) + +🚧 In development 🚧 + +- Authentication: Add the login screen to the new flow and support SSO on both login and registration flows. ([#5654](https://github.com/vector-im/element-ios/issues/5654)) + + ## Changes in 1.8.17 (2022-05-31) 🙌 Improvements diff --git a/changelog.d/5654.wip b/changelog.d/5654.wip deleted file mode 100644 index 14cfefb08..000000000 --- a/changelog.d/5654.wip +++ /dev/null @@ -1 +0,0 @@ -Authentication: Add the login screen to the new flow and support SSO on both login and registration flows. diff --git a/changelog.d/5746.change b/changelog.d/5746.change deleted file mode 100644 index c7a5f64b2..000000000 --- a/changelog.d/5746.change +++ /dev/null @@ -1 +0,0 @@ -Show user indicators when paginating a room diff --git a/changelog.d/6176.change b/changelog.d/6176.change deleted file mode 100644 index 6c9cd693a..000000000 --- a/changelog.d/6176.change +++ /dev/null @@ -1 +0,0 @@ -Authentication: Display fallback screens on registration & login according to the HS needs. diff --git a/changelog.d/6202.bugfix b/changelog.d/6202.bugfix deleted file mode 100644 index f88ba6a7a..000000000 --- a/changelog.d/6202.bugfix +++ /dev/null @@ -1 +0,0 @@ -Location sharing: Improve automatic detection of pin drop state diff --git a/changelog.d/6214.change b/changelog.d/6214.change deleted file mode 100644 index 0a99ba8d9..000000000 --- a/changelog.d/6214.change +++ /dev/null @@ -1 +0,0 @@ -WellKnown: support outbound keys presharing strategy diff --git a/changelog.d/pr-6196.build b/changelog.d/pr-6196.build deleted file mode 100644 index 00ba642a6..000000000 --- a/changelog.d/pr-6196.build +++ /dev/null @@ -1 +0,0 @@ -Ensure that warnings from CocoaPods dependencies do not show up in Xcode diff --git a/changelog.d/pr-6204.build b/changelog.d/pr-6204.build deleted file mode 100644 index cb30f7c8b..000000000 --- a/changelog.d/pr-6204.build +++ /dev/null @@ -1 +0,0 @@ -CI: Use macOS 12 and Xcode 13.4 diff --git a/changelog.d/x-nolink-0.change b/changelog.d/x-nolink-0.change deleted file mode 100644 index 75d07d225..000000000 --- a/changelog.d/x-nolink-0.change +++ /dev/null @@ -1 +0,0 @@ -Upgrade MatrixSDK version ([v0.23.8](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.23.8)). \ No newline at end of file