diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index cccba278d..a08546787 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -385,6 +385,8 @@ final class BuildSettings: NSObject { // MARK: - Onboarding static let onboardingShowAccountPersonalization = false + static let onboardingEnableNewAuthenticationFlow = false + static let onboardingHostYourOwnServerLink = URL(string: "https://element.io/contact-sales")! // MARK: - Unified Search static let unifiedSearchScreenShowPublicDirectory = true diff --git a/DesignKit/Source/ColorValues.swift b/DesignKit/Source/ColorValues.swift index 5694a5503..4e693a24e 100644 --- a/DesignKit/Source/ColorValues.swift +++ b/DesignKit/Source/ColorValues.swift @@ -46,5 +46,9 @@ public struct ColorValues: Colors { public let background: UIColor + public let white: UIColor + + public let ems: UIColor + public let namesAndAvatars: [UIColor] } diff --git a/DesignKit/Source/Colors.swift b/DesignKit/Source/Colors.swift index d7c885e59..dd19287cc 100644 --- a/DesignKit/Source/Colors.swift +++ b/DesignKit/Source/Colors.swift @@ -55,7 +55,7 @@ public protocol Colors { /// Separating line var separator: ColorType { get } - // Cards, tiles + /// Cards, tiles var tile: ColorType { get } /// Top navigation background on iOS @@ -64,6 +64,12 @@ public protocol Colors { /// Background UI color var background: ColorType { get } + /// Global color: The color white. + var white: ColorType { get } + + /// Global color: The EMS brand's purple colour. + var ems: ColorType { get } + /// - Names in chat timeline /// - Avatars default states that include first name letter var namesAndAvatars: [ColorType] { get } diff --git a/DesignKit/Source/ColorsSwiftUI.swift b/DesignKit/Source/ColorsSwiftUI.swift index 701aee537..0dade5618 100644 --- a/DesignKit/Source/ColorsSwiftUI.swift +++ b/DesignKit/Source/ColorsSwiftUI.swift @@ -47,6 +47,10 @@ public struct ColorSwiftUI: Colors { public let background: Color + public var white: Color + + public var ems: Color + public let namesAndAvatars: [Color] init(values: ColorValues) { @@ -62,6 +66,8 @@ public struct ColorSwiftUI: Colors { tile = Color(values.tile) navigation = Color(values.navigation) background = Color(values.background) + white = Color(values.white) + ems = Color(values.ems) namesAndAvatars = values.namesAndAvatars.map({ Color($0) }) } } diff --git a/DesignKit/Variants/Colors/Dark/DarkColors.swift b/DesignKit/Variants/Colors/Dark/DarkColors.swift index b6b0ba5ed..30d4d111a 100644 --- a/DesignKit/Variants/Colors/Dark/DarkColors.swift +++ b/DesignKit/Variants/Colors/Dark/DarkColors.swift @@ -33,6 +33,8 @@ public class DarkColors { tile: UIColor(rgb:0x394049), navigation: UIColor(rgb:0x21262C), background: UIColor(rgb:0x15191E), + white: UIColor(rgb: 0xFFFFFF), + ems: UIColor(rgb: 0x7E69FF), namesAndAvatars: [ UIColor(rgb:0x368BD6), UIColor(rgb:0xAC3BA8), diff --git a/DesignKit/Variants/Colors/Light/LightColors.swift b/DesignKit/Variants/Colors/Light/LightColors.swift index 2e7d8147a..972c75ff6 100644 --- a/DesignKit/Variants/Colors/Light/LightColors.swift +++ b/DesignKit/Variants/Colors/Light/LightColors.swift @@ -34,6 +34,8 @@ public class LightColors { tile: UIColor(rgb:0xF3F8FD), navigation: UIColor(rgb:0xF4F6FA), background: UIColor(rgb:0xFFFFFF), + white: UIColor(rgb: 0xFFFFFF), + ems: UIColor(rgb: 0x7E69FF), namesAndAvatars: [ UIColor(rgb:0x368BD6), UIColor(rgb:0xAC3BA8), diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/Contents.json new file mode 100644 index 000000000..181ccf902 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_server_selection_ems_logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/authentication_server_selection_ems_logo.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/authentication_server_selection_ems_logo.svg new file mode 100644 index 000000000..4d40f5a47 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/authentication_server_selection_ems_logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/Contents.json new file mode 100644 index 000000000..fec295dec --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_server_selection_icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/authentication_server_selection_icon.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/authentication_server_selection_icon.svg new file mode 100644 index 000000000..17b23458e --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/authentication_server_selection_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/Contents.json new file mode 100644 index 000000000..eddbc14dc --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_apple.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/authentication_sso_apple.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/authentication_sso_apple.svg new file mode 100644 index 000000000..2d1d3b3d8 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/authentication_sso_apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/Contents.json new file mode 100644 index 000000000..c087db709 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_facebook.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/authentication_sso_facebook.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/authentication_sso_facebook.svg new file mode 100644 index 000000000..21a4b1fe2 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/authentication_sso_facebook.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/Contents.json new file mode 100644 index 000000000..3fa20bfef --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_github.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/authentication_sso_github.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/authentication_sso_github.svg new file mode 100644 index 000000000..91ad466f1 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/authentication_sso_github.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/Contents.json new file mode 100644 index 000000000..80f0a4dc6 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_gitlab.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/authentication_sso_gitlab.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/authentication_sso_gitlab.svg new file mode 100644 index 000000000..885be976d --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/authentication_sso_gitlab.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/Contents.json new file mode 100644 index 000000000..7fb05cc39 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_google.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/authentication_sso_google.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/authentication_sso_google.svg new file mode 100644 index 000000000..e402ec590 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/authentication_sso_google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/Contents.json new file mode 100644 index 000000000..3ebd5a43f --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_twitter.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/authentication_sso_twitter.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/authentication_sso_twitter.svg new file mode 100644 index 000000000..f1bf030ce --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/authentication_sso_twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index 30d8ca51b..c1cde0786 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -20,6 +20,28 @@ "image_picker_action_files" = "Choose from files"; +// 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_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"; +"authentication_server_selection_server_footer" = "You can only connect to a server that has already been set up"; +"authentication_server_selection_ems_title" = "Want to host your own server?"; +/* This string will be followed by authentication_server_selection_ems_link on the next line. */ +"authentication_server_selection_ems_message" = "Element Matrix Services (EMS) is a robust and reliable hosting service for fast, secure real time communication. Find out how on"; +"authentication_server_selection_ems_link" = "element.io/ems"; +"authentication_server_selection_ems_button" = "Get in touch"; +"authentication_server_selection_generic_error" = "Cannot find a server at this URL, please check it is correct."; + +// MARK: Spaces WIP "spaces_feature_not_available" = "This feature isn't available here. For now, you can do this with %@ on your computer."; "leave_space_action" = "Leave space"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 9f91925e1..83e645684 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -30,6 +30,14 @@ internal class Asset: NSObject { internal static let socialLoginButtonGitlab = ImageAsset(name: "social_login_button_gitlab") internal static let socialLoginButtonGoogle = ImageAsset(name: "social_login_button_google") internal static let socialLoginButtonTwitter = ImageAsset(name: "social_login_button_twitter") + internal static let authenticationServerSelectionEmsLogo = ImageAsset(name: "authentication_server_selection_ems_logo") + internal static let authenticationServerSelectionIcon = ImageAsset(name: "authentication_server_selection_icon") + internal static let authenticationSsoIconApple = ImageAsset(name: "authentication_sso_icon_apple") + internal static let authenticationSsoIconFacebook = ImageAsset(name: "authentication_sso_icon_facebook") + internal static let authenticationSsoIconGithub = ImageAsset(name: "authentication_sso_icon_github") + internal static let authenticationSsoIconGitlab = ImageAsset(name: "authentication_sso_icon_gitlab") + internal static let authenticationSsoIconGoogle = ImageAsset(name: "authentication_sso_icon_google") + internal static let authenticationSsoIconTwitter = ImageAsset(name: "authentication_sso_icon_twitter") internal static let callAudioMuteOffIcon = ImageAsset(name: "call_audio_mute_off_icon") internal static let callAudioMuteOnIcon = ImageAsset(name: "call_audio_mute_on_icon") internal static let callAudioRouteBuiltin = ImageAsset(name: "call_audio_route_builtin") diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index 87b69348c..c681cd91d 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -10,6 +10,74 @@ import Foundation // swiftlint:disable function_parameter_count identifier_name line_length type_body_length public extension VectorL10n { + /// 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") + } + /// Username + static var authenticationRegistrationUsername: String { + return VectorL10n.tr("Untranslated", "authentication_registration_username") + } + /// You can’t change this later + static var authenticationRegistrationUsernameFooter: String { + return VectorL10n.tr("Untranslated", "authentication_registration_username_footer") + } + /// Get in touch + static var authenticationServerSelectionEmsButton: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_ems_button") + } + /// element.io/ems + static var authenticationServerSelectionEmsLink: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_ems_link") + } + /// Element Matrix Services (EMS) is a robust and reliable hosting service for fast, secure real time communication. Find out how on + static var authenticationServerSelectionEmsMessage: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_ems_message") + } + /// Want to host your own server? + static var authenticationServerSelectionEmsTitle: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_ems_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") + } + /// What is the address of your server? A server is like a home for all your data. + static var authenticationServerSelectionMessage: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_message") + } + /// You can only connect to a server that has already been set up + static var authenticationServerSelectionServerFooter: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_server_footer") + } + /// Server URL + static var authenticationServerSelectionServerUrl: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_server_url") + } + /// Choose your server + static var authenticationServerSelectionTitle: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_title") + } /// Choose from files static var imagePickerActionFiles: String { return VectorL10n.tr("Untranslated", "image_picker_action_files") diff --git a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift index 54696e503..59db49bb1 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift @@ -18,12 +18,6 @@ import Foundation -struct AuthenticationCoordinatorParameters { - let navigationRouter: NavigationRouterType - /// Whether or not the coordinator should show the loading spinner, key verification etc. - let canPresentAdditionalScreens: Bool -} - enum AuthenticationCoordinatorResult { /// The user has authenticated but key verification is yet to happen. The session value is /// for a fresh session that still needs to load, sync etc before being ready. diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift similarity index 81% rename from Riot/Modules/Authentication/AuthenticationCoordinator.swift rename to Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift index 3bcae3348..a5df78a33 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift @@ -18,8 +18,14 @@ import UIKit -/// A coordinator that handles authentication, verification and setting a PIN. -final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtocol { +struct LegacyAuthenticationCoordinatorParameters { + let navigationRouter: NavigationRouterType + /// Whether or not the coordinator should show the loading spinner, key verification etc. + let canPresentAdditionalScreens: Bool +} + +/// A coordinator that handles authentication, verification and setting a PIN using the old UIViewController flow for iOS 12 & 13. +final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtocol { // MARK: - Properties @@ -52,7 +58,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc // MARK: - Setup - init(parameters: AuthenticationCoordinatorParameters) { + init(parameters: LegacyAuthenticationCoordinatorParameters) { self.navigationRouter = parameters.navigationRouter self.canPresentAdditionalScreens = parameters.canPresentAdditionalScreens @@ -121,7 +127,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc private func presentCompleteSecurity() { guard let session = session else { - MXLog.error("[AuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") + MXLog.error("[LegacyAuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") authenticationDidComplete() return } @@ -152,7 +158,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc @objc private func sessionStateDidChange(_ notification: Notification) { guard let session = notification.object as? MXSession else { - MXLog.error("[AuthenticationCoordinator] sessionStateDidChange: Missing session in the notification") + MXLog.error("[LegacyAuthenticationCoordinator] sessionStateDidChange: Missing session in the notification") return } @@ -170,7 +176,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc crossSigning.refreshState { [weak self] stateUpdated in guard let self = self else { return } - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: crossSigning.state: \(crossSigning.state)") + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: crossSigning.state: \(crossSigning.state)") switch crossSigning.state { case .notBootstrapped: @@ -181,23 +187,23 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc // Bootstrap cross-signing on user's account // We do it for both registration and new login as long as cross-signing does not exist yet if let password = self.password, !password.isEmpty { - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap with password") + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Bootstrap with password") crossSigning.setup(withPassword: password) { - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap succeeded") + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Bootstrap succeeded") self.authenticationDidComplete() } failure: { error in - MXLog.error("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap failed. Error: \(error)") + MXLog.error("[LegacyAuthenticationCoordinator] sessionStateDidChange: Bootstrap failed. Error: \(error)") crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.authenticationDidComplete() } } else { // Try to setup cross-signing without authentication parameters in case if a grace period is enabled self.crossSigningService.setupCrossSigningWithoutAuthentication(for: session) { - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap succeeded without credentials") + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Bootstrap succeeded without credentials") self.authenticationDidComplete() } failure: { error in - MXLog.error("[AuthenticationCoordinator] sessionStateDidChange: Do not know how to bootstrap cross-signing. Skip it.") + MXLog.error("[LegacyAuthenticationCoordinator] sessionStateDidChange: Do not know how to bootstrap cross-signing. Skip it.") crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.authenticationDidComplete() } @@ -208,21 +214,21 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } case .crossSigningExists: guard self.canPresentAdditionalScreens else { - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Delaying presentCompleteSecurity during onboarding.") + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Delaying presentCompleteSecurity during onboarding.") self.isWaitingToPresentCompleteSecurity = true return } - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Complete security") + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Complete security") self.presentCompleteSecurity() default: - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Nothing to do") + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Nothing to do") crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self.authenticationDidComplete() } } failure: { [weak self] error in - MXLog.error("[AuthenticationCoordinator] sessionStateDidChange: Fail to refresh crypto state with error: \(error)") + MXLog.error("[LegacyAuthenticationCoordinator] sessionStateDidChange: Fail to refresh crypto state with error: \(error)") crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) self?.authenticationDidComplete() } @@ -234,7 +240,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } // MARK: - AuthenticationViewControllerDelegate -extension AuthenticationCoordinator: AuthenticationViewControllerDelegate { +extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate { func authenticationViewController(_ authenticationViewController: AuthenticationViewController!, didLoginWith session: MXSession!, andPassword password: String!) { registerSessionStateChangeNotification(for: session) @@ -249,11 +255,11 @@ extension AuthenticationCoordinator: AuthenticationViewControllerDelegate { } // MARK: - KeyVerificationCoordinatorDelegate -extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { +extension LegacyAuthenticationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { if let crypto = session?.crypto, !crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled { - MXLog.debug("[AuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") + MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) } @@ -270,7 +276,7 @@ extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { } // MARK: - UIAdaptivePresentationControllerDelegate -extension AuthenticationCoordinator: UIAdaptivePresentationControllerDelegate { +extension LegacyAuthenticationCoordinator: UIAdaptivePresentationControllerDelegate { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { // Prevent Key Verification from using swipe to dismiss return false diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift new file mode 100644 index 000000000..e801a7508 --- /dev/null +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -0,0 +1,405 @@ +// File created from ScreenTemplate +// $ createScreen.sh Onboarding Authentication +/* + 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 UIKit +import MatrixSDK + +@available(iOS 14.0, *) +struct AuthenticationCoordinatorParameters { + let navigationRouter: NavigationRouterType + /// The screen that should be shown when starting the flow. + let initialScreen: AuthenticationCoordinator.EntryPoint + /// Whether or not the coordinator should show the loading spinner, key verification etc. + let canPresentAdditionalScreens: Bool +} + +/// A coordinator that handles authentication, verification and setting a PIN. +@available(iOS 14.0, *) +final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtocol { + + enum EntryPoint { + case registration + case selectServerForRegistration + case login + } + + // MARK: - Properties + + // MARK: Private + + private let navigationRouter: NavigationRouterType + private let authenticationService = AuthenticationService.shared + + private let initialScreen: EntryPoint + private var canPresentAdditionalScreens: Bool + private var isWaitingToPresentCompleteSecurity = false + + private let crossSigningService = CrossSigningService() + + /// The password entered, for use when setting up cross-signing. + private var password: String? + /// The session created when successfully authenticated. + private var session: MXSession? + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((AuthenticationCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationCoordinatorParameters) { + self.navigationRouter = parameters.navigationRouter + self.initialScreen = parameters.initialScreen + self.canPresentAdditionalScreens = parameters.canPresentAdditionalScreens + + super.init() + } + + // MARK: - Public + + func start() { + Task { + #warning("Catch any errors and handle them") + let (loginFlowResult, registrationResult) = try await authenticationService.refreshServer(homeserverAddress: authenticationService.homeserverAddress) + + if case let .success(session) = registrationResult { + onSessionCreated(session: session, isAccountCreated: true) + return + } + + await MainActor.run { + switch initialScreen { + case .registration: + showRegistrationScreen(registrationResult: registrationResult, loginFlowResult: loginFlowResult) + case .selectServerForRegistration: + showServerSelectionScreen() + case .login: + showLoginScreen() + } + } + } + } + + func toPresentable() -> UIViewController { + navigationRouter.toPresentable() + } + + func presentPendingScreensIfNecessary() { + canPresentAdditionalScreens = true + + showLoadingAnimation() + + if isWaitingToPresentCompleteSecurity { + isWaitingToPresentCompleteSecurity = false + presentCompleteSecurity() + } + } + + // MARK: - Registration + + /// Pushes the server selection screen into the flow (other screens may also present it modally later). + @MainActor private func showServerSelectionScreen() { + MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") + let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, + hasModalPresentation: false) + let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters) + coordinator.completion = { [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) + + if navigationRouter.modules.isEmpty { + navigationRouter.setRootModule(coordinator, popCompletion: nil) + } else { + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + } + + @available(iOS 14.0, *) + /// Shows the next screen in the flow after the server selection screen. + @MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator, + didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) { + switch result { + case .updated(let loginFlow, let registrationResult): + showRegistrationScreen(registrationResult: registrationResult, loginFlowResult: loginFlow) + case .dismiss: + MXLog.failure("[AuthenticationCoordinator] AuthenticationServerSelectionScreen is requesting dismiss when part of a stack.") + } + } + + /// Shows the registration screen. + @MainActor private func showRegistrationScreen(registrationResult: RegistrationResult, loginFlowResult: LoginFlowResult) { + MXLog.debug("[AuthenticationCoordinator] showRegistrationScreen") + let parameters = AuthenticationRegistrationCoordinatorParameters(navigationRouter: navigationRouter, + authenticationService: authenticationService, + registrationResult: registrationResult, + loginFlowResult: loginFlowResult) + let coordinator = AuthenticationRegistrationCoordinator(parameters: parameters) + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.registrationCoordinator(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. + @available(iOS 14.0, *) + @MainActor private func registrationCoordinator(_ coordinator: AuthenticationRegistrationCoordinator, didCompleteWith result: AuthenticationRegistrationCoordinatorResult) { + switch result { + case .selectServer: + showServerSelectionScreen() + case .flowResponse(let flowResult): + showNextScreen(for: flowResult) + case .sessionCreated(let session, let isAccountCreated): + onSessionCreated(session: session, isAccountCreated: isAccountCreated) + } + } + + /// 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. + func showNextScreen(for flowResult: FlowResult) { + // TODO + } + + /// Handles the creation of a new session following on from a successful authentication. + func onSessionCreated(session: MXSession, isAccountCreated: Bool) { + registerSessionStateChangeNotification(for: session) + + self.session = session + // self.password = password + + if canPresentAdditionalScreens { + showLoadingAnimation() + } + + completion?(.didLogin(session: session, authenticationType: isAccountCreated ? .register : .login)) + } + + // MARK: - Additional Screens + + /// Replace the contents of the navigation router with a loading animation. + private func showLoadingAnimation() { + let loadingViewController = LaunchLoadingViewController() + loadingViewController.modalPresentationStyle = .fullScreen + + // Replace the navigation stack with the loading animation + // as there is nothing to navigate back to. + navigationRouter.setRootModule(loadingViewController) + } + + /// 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.") + authenticationDidComplete() + return + } + + let isNewSignIn = true + let cancellable = !session.vc_homeserverConfiguration().encryption.isSecureBackupRequired + let keyVerificationCoordinator = KeyVerificationCoordinator(session: session, flow: .completeSecurity(isNewSignIn), cancellable: cancellable) + + keyVerificationCoordinator.delegate = self + let presentable = keyVerificationCoordinator.toPresentable() + presentable.presentationController?.delegate = self + navigationRouter.present(presentable, animated: true) + keyVerificationCoordinator.start() + add(childCoordinator: keyVerificationCoordinator) + } + + /// Complete the authentication flow. + private func authenticationDidComplete() { + completion?(.didComplete) + } + + private func registerSessionStateChangeNotification(for session: MXSession) { + NotificationCenter.default.addObserver(self, selector: #selector(sessionStateDidChange), name: .mxSessionStateDidChange, object: session) + } + + private func unregisterSessionStateChangeNotification() { + NotificationCenter.default.removeObserver(self, name: .mxSessionStateDidChange, object: nil) + } + + @objc private func sessionStateDidChange(_ notification: Notification) { + guard let session = notification.object as? MXSession else { + MXLog.error("[LegacyAuthenticationCoordinator] sessionStateDidChange: Missing session in the notification") + return + } + + if session.state == .storeDataReady { + if let crypto = session.crypto, crypto.crossSigning != nil { + // Do not make key share requests while the "Complete security" is not complete. + // If the device is self-verified, the SDK will restore the existing key backup. + // Then, it will re-enable outgoing key share requests + crypto.setOutgoingKeyRequestsEnabled(false, onComplete: nil) + } + } else if session.state == .running { + unregisterSessionStateChangeNotification() + + if let crypto = session.crypto, let crossSigning = crypto.crossSigning { + crossSigning.refreshState { [weak self] stateUpdated in + guard let self = self else { return } + + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: crossSigning.state: \(crossSigning.state)") + + switch crossSigning.state { + case .notBootstrapped: + // TODO: This is still not sure we want to disable the automatic cross-signing bootstrap + // if the admin disabled e2e by default. + // Do like riot-web for the moment + if session.vc_homeserverConfiguration().encryption.isE2EEByDefaultEnabled { + // Bootstrap cross-signing on user's account + // We do it for both registration and new login as long as cross-signing does not exist yet + if let password = self.password, !password.isEmpty { + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Bootstrap with password") + + crossSigning.setup(withPassword: password) { + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Bootstrap succeeded") + self.authenticationDidComplete() + } failure: { error in + MXLog.error("[LegacyAuthenticationCoordinator] sessionStateDidChange: Bootstrap failed. Error: \(error)") + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self.authenticationDidComplete() + } + } else { + // Try to setup cross-signing without authentication parameters in case if a grace period is enabled + self.crossSigningService.setupCrossSigningWithoutAuthentication(for: session) { + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Bootstrap succeeded without credentials") + self.authenticationDidComplete() + } failure: { error in + MXLog.error("[LegacyAuthenticationCoordinator] sessionStateDidChange: Do not know how to bootstrap cross-signing. Skip it.") + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self.authenticationDidComplete() + } + } + } else { + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self.authenticationDidComplete() + } + case .crossSigningExists: + guard self.canPresentAdditionalScreens else { + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Delaying presentCompleteSecurity during onboarding.") + self.isWaitingToPresentCompleteSecurity = true + return + } + + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Complete security") + self.presentCompleteSecurity() + default: + MXLog.debug("[LegacyAuthenticationCoordinator] sessionStateDidChange: Nothing to do") + + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self.authenticationDidComplete() + } + } failure: { [weak self] error in + MXLog.error("[LegacyAuthenticationCoordinator] sessionStateDidChange: Fail to refresh crypto state with error: \(error)") + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self?.authenticationDidComplete() + } + } else { + authenticationDidComplete() + } + } + } +} + +// MARK: - KeyVerificationCoordinatorDelegate +@available(iOS 14.0, *) +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") + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + } + + navigationRouter.dismissModule(animated: true) { [weak self] in + self?.authenticationDidComplete() + } + } + + func keyVerificationCoordinatorDidCancel(_ coordinator: KeyVerificationCoordinatorType) { + navigationRouter.dismissModule(animated: true) { [weak self] in + self?.authenticationDidComplete() + } + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate +@available(iOS 14.0, *) +extension AuthenticationCoordinator: UIAdaptivePresentationControllerDelegate { + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + // Prevent Key Verification from using swipe to dismiss + return false + } +} + + + +// MARK: - Unused conformances +@available(iOS 14.0, *) +extension AuthenticationCoordinator { + var customServerFieldsVisible: Bool { + get { false } + set { /* no-op */ } + } + + func update(authenticationType: MXKAuthenticationType) { + // unused + } + + func update(externalRegistrationParameters: [AnyHashable: Any]) { + // unused + } + + func update(softLogoutCredentials: MXCredentials) { + // unused + } + + 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/AuthenticationCoordinatorState.swift b/Riot/Modules/Onboarding/AuthenticationCoordinatorState.swift new file mode 100644 index 000000000..9eebfda03 --- /dev/null +++ b/Riot/Modules/Onboarding/AuthenticationCoordinatorState.swift @@ -0,0 +1,41 @@ +// +// 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 +import MatrixSDK + +@available(iOS 14.0, *) +struct AuthenticationCoordinatorState { + // MARK: User choices + // var serverType: ServerType = .unknown + // var signMode: SignMode = .unknown + var resetPasswordEmail: String? + + /// The homeserver address as returned by the server. + var homeserverAddress: String? + /// The homeserver address as input by the user (it can differ to the well-known request). + var homeserverAddressFromUser: String? + + /// For SSO session recovery + var deviceId: String? + + // MARK: Network result + var loginMode: LoginMode = .unknown + /// Supported types for the login. + var loginModeSupportedTypes = [MXLoginFlow]() + var knownCustomHomeServersUrls = [String]() + var isForceLoginFallbackEnabled = false +} diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 2b5753816..0d3d68906 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -56,8 +56,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } // Keep a strong ref as we need to init authVC early to preload its view private let 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 isShowingAuthentication = false + private var isShowingLegacyAuthentication = false // MARK: Screen results private var splashScreenResult: OnboardingSplashScreenViewModelResult? @@ -87,8 +88,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { self.parameters = parameters // Preload the authVC (it is *really* slow to load in realtime) - let authenticationParameters = AuthenticationCoordinatorParameters(navigationRouter: parameters.router, canPresentAdditionalScreens: false) - authenticationCoordinator = AuthenticationCoordinator(parameters: authenticationParameters) + let authenticationParameters = LegacyAuthenticationCoordinatorParameters(navigationRouter: parameters.router, canPresentAdditionalScreens: false) + authenticationCoordinator = LegacyAuthenticationCoordinator(parameters: authenticationParameters) super.init() } @@ -100,7 +101,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { if #available(iOS 14.0, *), parameters.softLogoutCredentials == nil, BuildSettings.authScreenShowRegister { showSplashScreen() } else { - showAuthenticationScreen() + showLegacyAuthenticationScreen() } } @@ -124,7 +125,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { /// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters. func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool { - guard isShowingAuthentication else { return false } + guard isShowingLegacyAuthentication else { return false } return authenticationCoordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID) } @@ -159,7 +160,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { case .register: showUseCaseSelectionScreen() case .login: - showAuthenticationScreen() + showLegacyAuthenticationScreen() } } @@ -190,16 +191,50 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { @available(iOS 14.0, *) private func useCaseSelectionCoordinator(_ coordinator: OnboardingUseCaseSelectionCoordinator, didCompleteWith result: OnboardingUseCaseViewModelResult) { useCaseResult = result - showAuthenticationScreen() + + guard BuildSettings.onboardingEnableNewAuthenticationFlow else { + showLegacyAuthenticationScreen() + return + } + + if result == .customServer { + beginAuthentication(with: .selectServerForRegistration) + } else { + beginAuthentication(with: .registration) + } } // MARK: - Authentication - /// Show the authentication screen. Any parameters that have been set in previous screens are be applied. - private func showAuthenticationScreen() { - guard !isShowingAuthentication else { return } + /// Show the authentication flow, starting at the specified initial screen. + @available(iOS 14.0, *) + private func beginAuthentication(with initialScreen: AuthenticationCoordinator.EntryPoint) { + MXLog.debug("[OnboardingCoordinator] beginAuthentication") - MXLog.debug("[OnboardingCoordinator] showAuthenticationScreen") + let parameters = AuthenticationCoordinatorParameters(navigationRouter: navigationRouter, + initialScreen: initialScreen, + canPresentAdditionalScreens: false) + let coordinator = AuthenticationCoordinator(parameters: parameters) + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + + switch result { + case .didLogin(let session, let authenticationType): + self.authenticationCoordinator(coordinator, didLoginWith: session, and: authenticationType) + case .didComplete: + self.authenticationCoordinatorDidComplete(coordinator) + } + } + + add(childCoordinator: coordinator) + coordinator.start() + } + + /// Show the legacy authentication screen. Any parameters that have been set in previous screens are be applied. + private func showLegacyAuthenticationScreen() { + guard !isShowingLegacyAuthentication else { return } + + MXLog.debug("[OnboardingCoordinator] showLegacyAuthenticationScreen") let coordinator = authenticationCoordinator coordinator.completion = { [weak self, weak coordinator] result in @@ -239,13 +274,13 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } else { navigationRouter.push(coordinator, animated: true) { [weak self] in self?.remove(childCoordinator: coordinator) - self?.isShowingAuthentication = false + self?.isShowingLegacyAuthentication = false } } - isShowingAuthentication = true + isShowingLegacyAuthentication = true } - /// Displays the next view in the flow after the authentication screen, + /// Displays the next view in the flow after the authentication screens, /// whilst crypto and the rest of the app is launching in the background. private func authenticationCoordinator(_ coordinator: AuthenticationCoordinatorProtocol, didLoginWith session: MXSession, @@ -295,9 +330,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } } - /// Displays the next view in the flow after the authentication screen. + /// Completes the onboarding flow if possible, otherwise waits for any remaining screens. private func authenticationCoordinatorDidComplete(_ coordinator: AuthenticationCoordinatorProtocol) { - isShowingAuthentication = false + isShowingLegacyAuthentication = false // Handle the chosen use case where applicable if authenticationType == .register, @@ -519,7 +554,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } guard authenticationFinished else { - MXLog.debug("[OnboardingCoordinator] Allowing AuthenticationCoordinator to display any remaining screens.") + MXLog.debug("[OnboardingCoordinator] Allowing LegacyAuthenticationCoordinator to display any remaining screens.") authenticationCoordinator.presentPendingScreensIfNecessary() return } diff --git a/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift b/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift index 32f433936..2286bc046 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift @@ -61,15 +61,7 @@ final class OnboardingCoordinatorBridgePresenter: NSObject { // MARK: - Public func present(from viewController: UIViewController, animated: Bool) { - let onboardingCoordinatorParameters = OnboardingCoordinatorParameters(softLogoutCredentials: parameters.softLogoutCredentials) - - let onboardingCoordinator = OnboardingCoordinator(parameters: onboardingCoordinatorParameters) - onboardingCoordinator.completion = { [weak self] in - self?.completion?() - } - if let externalRegistrationParameters = parameters.externalRegistrationParameters { - onboardingCoordinator.update(externalRegistrationParameters: externalRegistrationParameters) - } + let onboardingCoordinator = makeOnboardingCoordinator() let presentable = onboardingCoordinator.toPresentable() presentable.modalPresentationStyle = .fullScreen @@ -86,16 +78,7 @@ final class OnboardingCoordinatorBridgePresenter: NSObject { let navigationRouter = NavigationRouterStore.shared.navigationRouter(for: navigationController) - let onboardingCoordinatorParameters = OnboardingCoordinatorParameters(router: navigationRouter, - softLogoutCredentials: parameters.softLogoutCredentials) - - let onboardingCoordinator = OnboardingCoordinator(parameters: onboardingCoordinatorParameters) - onboardingCoordinator.completion = { [weak self] in - self?.completion?() - } - if let externalRegistrationParameters = parameters.externalRegistrationParameters { - onboardingCoordinator.update(externalRegistrationParameters: externalRegistrationParameters) - } + let onboardingCoordinator = makeOnboardingCoordinator(navigationRouter: navigationRouter) onboardingCoordinator.start() // Will trigger the view controller push @@ -148,4 +131,22 @@ final class OnboardingCoordinatorBridgePresenter: NSObject { } } } + + // MARK: - Private + + /// Makes an `OnboardingCoordinator` using the supplied navigation router, or creating one if needed. + private func makeOnboardingCoordinator(navigationRouter: NavigationRouterType? = nil) -> OnboardingCoordinator { + let onboardingCoordinatorParameters = OnboardingCoordinatorParameters(router: navigationRouter, + softLogoutCredentials: parameters.softLogoutCredentials) + + let onboardingCoordinator = OnboardingCoordinator(parameters: onboardingCoordinatorParameters) + onboardingCoordinator.completion = { [weak self] in + self?.completion?() + } + if let externalRegistrationParameters = parameters.externalRegistrationParameters { + onboardingCoordinator.update(externalRegistrationParameters: externalRegistrationParameters) + } + + return onboardingCoordinator + } } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationPendingData.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationPendingData.swift new file mode 100644 index 000000000..2aa4bc499 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationPendingData.swift @@ -0,0 +1,43 @@ +// +// 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 + +/// This class holds all pending data when creating a session, either by login or by register +class AuthenticationPendingData { + let homeserverAddress: String + + // MARK: - Common + + var clientSecret = UUID().uuidString + var sendAttempt: UInt = 0 + + // MARK: - For login + + // var resetPasswordData: ResetPasswordData? + + // MARK: - For registration + + var currentSession: String? + var isRegistrationStarted = false + var currentThreePIDData: ThreePIDData? + + // MARK: - Setup + + init(homeserverAddress: String) { + self.homeserverAddress = homeserverAddress + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationFlowHandling.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationFlowHandling.swift new file mode 100644 index 000000000..acede9b8d --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationFlowHandling.swift @@ -0,0 +1,72 @@ +// +// 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 + +@available(iOS 14.0, *) +/// A protocol with a default implementation that allows a coordinator to execute and handle registration flow steps. +protocol RegistrationFlowHandling { + var authenticationService: AuthenticationService { get } + var registrationWizard: RegistrationWizard { get } + @MainActor var completion: ((AuthenticationRegistrationCoordinatorResult) -> Void)? { get } + + /// Executes a registration step using the `RegistrationWizard` to complete any additional steps automatically. + @MainActor func executeRegistrationStep(step: @escaping (RegistrationWizard) async throws -> RegistrationResult) -> Task +} + +@available(iOS 14.0, *) +@MainActor extension RegistrationFlowHandling { + func executeRegistrationStep(step: @escaping (RegistrationWizard) async throws -> RegistrationResult) -> Task { + return Task { + do { + let result = try await step(registrationWizard) + + guard !Task.isCancelled else { return } + + switch result { + case .success(let mxSession): + completion?(.sessionCreated(session: mxSession, isAccountCreated: true)) + case .flowResponse(let flowResult): + await processFlowResponse(flowResult: flowResult) + } + } catch { + // An error is thrown only to indicate that a task failed, + // however it should be handled in the closure rather than here. + } + } + } + + /// Processes flow responses making sure the dummy stage is handled automatically when possible. + func processFlowResponse(flowResult: FlowResult) async { + // If dummy stage is mandatory, and password is already sent, do the dummy stage now + if authenticationService.isRegistrationStarted && flowResult.missingStages.contains(where: { $0.isDummyAndMandatory }) { + await handleRegisterDummy() + } else { + // Notify the user + completion?(.flowResponse(flowResult)) + } + } + + /// Handle the dummy stage of the flow. + func handleRegisterDummy() async { + let task = executeRegistrationStep { wizard in + try await wizard.dummy() + } + + // await the result to suspend until the request is complete. + let _ = await task.result + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift new file mode 100644 index 000000000..5061aa860 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift @@ -0,0 +1,124 @@ +// +// 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 AuthenticationRegistrationViewModelResult { + /// The user would like to select another server. + case selectServer + /// Validate the supplied username with the homeserver. + case validateUsername(String) + /// Create an account using the supplied credentials. + case createAccount(username: String, password: String) +} + +// MARK: View + +struct AuthenticationRegistrationViewState: BindableState { + /// The address of the homeserver. + var homeserverAddress: String + /// An array containing the available SSO options for login. + var ssoIdentityProviders: [SSOIdentityProvider] + /// View state that can be bound to from SwiftUI. + var bindings: AuthenticationRegistrationBindings + /// Whether or not the username field has been edited yet. + /// + /// This is used to delay showing an error state until the user has tried 1 username. + var hasEditedUsername = false + /// Whether or not the password field has been edited yet. + /// + /// This is used to delay showing an error state until the user has tried 1 password. + var hasEditedPassword = false + + /// An error message to be shown in the username text field footer. + var usernameErrorMessage: String? + + /// The message to show in the username text field footer. + var usernameFooterMessage: String { + 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.authenticationRegistrationMatrixDescription + } + + /// Whether or not to allow username and password text input. + var showRegistrationForm: Bool { + true + } + + /// Whether to show any SSO buttons. + var showSSOButtons: Bool { + !ssoIdentityProviders.isEmpty + } + + /// Whether the current `username` is valid. + var isUsernameValid: Bool { + !bindings.username.isEmpty && usernameErrorMessage == nil + } + + /// Whether the current `password` is valid. + var isPasswordValid: Bool { + bindings.password.count >= 8 + } + + /// `true` if it is possible to continue, otherwise `false`. + var hasValidCredentials: Bool { + isUsernameValid && isPasswordValid + } +} + +struct AuthenticationRegistrationBindings: BindableState { + /// 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 AuthenticationRegistrationViewAction { + /// The user would like to select another server. + case selectServer + /// Validate the supplied username with the homeserver. + case validateUsername + /// Allows password validation to take place (sent after editing the password for the first time). + case enablePasswordValidation + /// Clear any errors being shown in the username text field footer. + case clearUsernameError + /// Continue using the input username and password. + case next + /// Login using the supplied SSO provider ID. + case continueWithSSO(id: String) +} + +enum AuthenticationRegistrationErrorType: Hashable { + /// An error to be shown in the username text field footer. + case usernameUnavailable(String) + + /// 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 invalidResponse + /// An unknown error occurred. + case unknown +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift new file mode 100644 index 000000000..b41cd38de --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift @@ -0,0 +1,92 @@ +// +// 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 Combine + +@available(iOS 14, *) +typealias AuthenticationRegistrationViewModelType = StateStoreViewModel + + +@available(iOS 14, *) +class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelType, AuthenticationRegistrationViewModelProtocol { + + // MARK: - Properties + + // MARK: Public + + @MainActor var completion: ((AuthenticationRegistrationViewModelResult) -> Void)? + + // MARK: - Setup + + init(homeserverAddress: String, ssoIdentityProviders: [SSOIdentityProvider]) { + let bindings = AuthenticationRegistrationBindings() + let viewState = AuthenticationRegistrationViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress), + ssoIdentityProviders: ssoIdentityProviders, + bindings: bindings) + + super.init(initialViewState: viewState) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationRegistrationViewAction) { + Task { + await MainActor.run { + switch viewAction { + case .selectServer: + completion?(.selectServer) + case .validateUsername: + state.hasEditedUsername = true + completion?(.validateUsername(state.bindings.username)) + case .enablePasswordValidation: + state.hasEditedPassword = true + case .clearUsernameError: + guard state.usernameErrorMessage != nil else { return } + state.usernameErrorMessage = nil + case .next: + completion?(.createAccount(username: state.bindings.username, password: state.bindings.password)) + case .continueWithSSO(let id): + break + } + } + } + } + + @MainActor func update(homeserverAddress: String, ssoIdentityProviders: [SSOIdentityProvider]) { + state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress) + state.ssoIdentityProviders = ssoIdentityProviders + } + + @MainActor func displayError(_ type: AuthenticationRegistrationErrorType) { + switch type { + case .usernameUnavailable(let message): + state.usernameErrorMessage = message + case .mxError(let message): + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: message) + case .invalidHomeserver, .invalidResponse: + 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/Registration/AuthenticationRegistrationViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift new file mode 100644 index 000000000..2c5919ba3 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.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 AuthenticationRegistrationViewModelProtocol { + + @MainActor var completion: ((AuthenticationRegistrationViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: AuthenticationRegistrationViewModelType.Context { get } + + /// Update the view with new homeserver information. + /// - Parameters: + /// - homeserverAddress: The homeserver string to be shown to the user. + /// - ssoIdentityProviders: The supported SSO login options. + @MainActor func update(homeserverAddress: String, ssoIdentityProviders: [SSOIdentityProvider]) + + /// 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 new file mode 100644 index 000000000..5d32d1966 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -0,0 +1,240 @@ +// +// 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 + +@available(iOS 14.0, *) +struct AuthenticationRegistrationCoordinatorParameters { + let navigationRouter: NavigationRouterType + let authenticationService: AuthenticationService + /// The registration flows that are to be displayed. + let registrationResult: RegistrationResult + /// The login flows to allow for SSO sign up. + let loginFlowResult: LoginFlowResult +} + +enum AuthenticationRegistrationCoordinatorResult { + /// The user would like to select another server. + case selectServer + /// The screen completed but there are remaining authentication steps. + case flowResponse(FlowResult) + /// The screen completed with a successful login. + case sessionCreated(session: MXSession, isAccountCreated: Bool) +} + +@available(iOS 14.0, *) +final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationRegistrationCoordinatorParameters + private let authenticationRegistrationHostingController: VectorHostingController + private var authenticationRegistrationViewModel: AuthenticationRegistrationViewModelProtocol + + 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 registration. + var authenticationService: AuthenticationService { parameters.authenticationService } + /// The wizard used to handle the registration flow. + var registrationWizard: RegistrationWizard + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + @MainActor var completion: ((AuthenticationRegistrationCoordinatorResult) -> Void)? + + // MARK: - Setup + + @MainActor init(parameters: AuthenticationRegistrationCoordinatorParameters) { + self.parameters = parameters + + do { + let registrationWizard = try parameters.authenticationService.registrationWizard() + self.registrationWizard = registrationWizard + + let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: registrationWizard.pendingData.homeserverAddress, + ssoIdentityProviders: parameters.loginFlowResult.ssoIdentityProviders) + authenticationRegistrationViewModel = viewModel + + let view = AuthenticationRegistrationScreen(viewModel: viewModel.context) + authenticationRegistrationHostingController = VectorHostingController(rootView: view) + authenticationRegistrationHostingController.vc_removeBackTitle() + authenticationRegistrationHostingController.enableNavigationBarScrollEdgeAppearance = true + } catch { + MXLog.failure("[AuthenticationRegistrationCoordinator] The registration wizard was requested before getting the login flow.") + fatalError(error.localizedDescription) + } + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationRegistrationHostingController) + } + + // MARK: - Public + func start() { + Task { + await MainActor.run { + MXLog.debug("[AuthenticationRegistrationCoordinator] did start.") + authenticationRegistrationViewModel.completion = { [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() + case.validateUsername(let username): + self.validateUsername(username) + case .createAccount(let username, let password): + self.createAccount(username: username, password: password) + } + } + } + } + } + + func toPresentable() -> UIViewController { + return self.authenticationRegistrationHostingController + } + + // MARK: - Private + + /// Show a blocking activity indicator whilst saving. + @MainActor private func startLoading(label: String? = nil) { + waitingIndicator = indicatorPresenter.present(.loading(label: label ?? VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + @MainActor private func stopLoading() { + waitingIndicator = nil + } + + /// Asks the homeserver to check the supplied username's format and availability. + @MainActor private func validateUsername(_ username: String) { + currentTask = Task { + do { + _ = try await registrationWizard.registrationAvailable(username: username) + } catch { + guard !Task.isCancelled, let mxError = MXError(nsError: error as NSError) else { return } + if mxError.errcode == kMXErrCodeStringUserInUse + || mxError.errcode == kMXErrCodeStringInvalidUsername + || mxError.errcode == kMXErrCodeStringExclusiveResource { + authenticationRegistrationViewModel.displayError(.usernameUnavailable(mxError.error)) + } + } + } + } + + /// Creates an account on the homeserver with the supplied username and password. + @MainActor private func createAccount(username: String, password: String) { + // reAuthHelper.data = state.password + let deviceDisplayName = UIDevice.current.isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice + + startLoading() + + currentTask = executeRegistrationStep { [weak self] wizard in + defer { Task { [weak self] in await self?.stopLoading() } } + + do { + return try await wizard.createAccount(username: username, password: password, initialDeviceDisplayName: deviceDisplayName) + } catch { + self?.handleError(error) + throw error // Throw the error as there is nothing to return (it will be swallowed up by executeRegistrationStep). + } + } + } + + /// 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) { + authenticationRegistrationViewModel.displayError(.mxError(mxError.error)) + return + } + + if let authenticationError = error as? AuthenticationError { + switch authenticationError { + case .invalidHomeserver: + authenticationRegistrationViewModel.displayError(.invalidHomeserver) + case .dictionaryError: + authenticationRegistrationViewModel.displayError(.unknown) + case .loginFlowNotCalled, .missingRegistrationWizard, .createAccountNotCalled: + #warning("Reset the flow") + break + case .missingMXRestClient: + #warning("Forget the soft logout session") + break + case .noPendingThreePID, .missingThreePIDURL, .threePIDClientFailure, .threePIDValidationFailure: + // Shouldn't happen at this stage + authenticationRegistrationViewModel.displayError(.unknown) + } + return + } + + authenticationRegistrationViewModel.displayError(.unknown) + } + + /// 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.completion = { [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 case let .updated(loginFlow, registrationResult) = result { + + authenticationRegistrationViewModel.update(homeserverAddress: authenticationService.homeserverAddress, + ssoIdentityProviders: loginFlow.ssoIdentityProviders) + + do { + registrationWizard = try authenticationService.registrationWizard() + } catch { + MXLog.failure("[AuthenticationRegistrationCoordinator] The registration wizard was requested before getting the login flow: \(error.localizedDescription)") + } + } + + navigationRouter.dismissModule(animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } +} + +@available(iOS 14, *) +extension AuthenticationRegistrationCoordinator: RegistrationFlowHandling { } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift new file mode 100644 index 000000000..30d7bc851 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift @@ -0,0 +1,78 @@ +// +// 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. +@available(iOS 14.0, *) +enum MockAuthenticationRegistrationScreenState: 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 passwordWithUsernameError + case ssoOnly + + /// The associated screen + var screenType: Any.Type { + AuthenticationRegistrationScreen.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + 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) + ]) + case .passwordOnly: + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + case .passwordWithCredentials: + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel.context.username = "alice" + viewModel.context.password = "password" + case .passwordWithUsernameError: + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel.state.hasEditedUsername = true + Task { + await MainActor.run { + viewModel.displayError(.usernameUnavailable(VectorL10n.authInvalidUserName)) + } + } + case .ssoOnly: + // TODO: As part of the login flow the screen should be shared and made be more configurable. Right now password registration is hard coded in. + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://company.com", ssoIdentityProviders: [ + SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil) + ]) + } + + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], AnyView(AuthenticationRegistrationScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift new file mode 100644 index 000000000..a92e1957f --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift @@ -0,0 +1,145 @@ +// +// 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 + +@available(iOS 14.0, *) +class AuthenticationRegistrationUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockAuthenticationRegistrationScreenState.self + } + + override class func createTest() -> MockScreenTest { + return AuthenticationRegistrationUITests(selector: #selector(verifyAuthenticationRegistrationScreen)) + } + + func verifyAuthenticationRegistrationScreen() throws { + guard let screenState = screenState as? MockAuthenticationRegistrationScreenState else { fatalError("no screen") } + switch screenState { + case .matrixDotOrg: + let state = "matrix.org" + validateRegistrationFormIsVisible(for: state) + validateSSOButtonsAreShown(for: state) + + validateNoErrorsAreShown(for: state) + case .passwordOnly: + let state = "a password only server" + validateRegistrationFormIsVisible(for: state) + validateSSOButtonsAreHidden(for: state) + + validateNextButtonIsDisabled(for: state) + + validateNoErrorsAreShown(for: state) + case .passwordWithCredentials: + let state = "a password only server with credentials entered" + validateRegistrationFormIsVisible(for: state) + validateSSOButtonsAreHidden(for: state) + + validateNextButtonIsEnabled(for: state) + + validateNoErrorsAreShown(for: state) + case .passwordWithUsernameError: + let state = "a password only server with an invalid username" + validateRegistrationFormIsVisible(for: state) + validateSSOButtonsAreHidden(for: state) + + validateNextButtonIsDisabled(for: state) + + validateUsernameError(for: state) + case .ssoOnly: + let state = "an SSO only server" + // TODO: The SSO only configuration still needs implementation. + // validateRegistrationFormIsHidden(for: state) + validateSSOButtonsAreShown(for: state) + } + } + + /// Checks that the username and password text fields are shown along with the next button. + func validateRegistrationFormIsVisible(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 validateRegistrationFormIsHidden(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).") + } + + /// Checks that the username text field footer is showing an error. + func validateUsernameError(for state: String) { + let usernameFooter = textFieldFooter(for: "usernameTextField") + XCTAssertTrue(usernameFooter.exists, "The username footer should be shown for \(state).") + XCTAssertEqual(usernameFooter.label, VectorL10n.authInvalidUserName, "The username footer should be showing an error for \(state).") + } + + /// Checks that neither the username or password text field footers are showing an error. + func validateNoErrorsAreShown(for state: String) { + let usernameFooter = textFieldFooter(for: "usernameTextField") + let passwordFooter = textFieldFooter(for: "passwordTextField") + + XCTAssertTrue(usernameFooter.exists, "The username footer should be shown for \(state).") + XCTAssertTrue(passwordFooter.exists, "The password footer should be shown for \(state).") + XCTAssertEqual(usernameFooter.label, VectorL10n.authenticationRegistrationUsernameFooter, + "The username footer should be showing the default message for \(state).") + XCTAssertEqual(passwordFooter.label, VectorL10n.authenticationRegistrationPasswordFooter, + "The password footer should be showing the default message for \(state).") + } + + /// Gets the text field footer for the supplied identifier. + func textFieldFooter(for identifier: String) -> XCUIElement { + let matches = app.staticTexts.matching(identifier: identifier) + return matches.element(boundBy: matches.count - 1) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift new file mode 100644 index 000000000..e18d939d7 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift @@ -0,0 +1,199 @@ +// +// 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 Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +@MainActor class AuthenticationRegistrationViewModelTests: XCTestCase { + var viewModel: AuthenticationRegistrationViewModelProtocol! + var context: AuthenticationRegistrationViewModelType.Context! + + @MainActor override func setUp() async throws { + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "", ssoIdentityProviders: []) + 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) + ] + + // When updating the view model with the server. + viewModel.update(homeserverAddress: address, 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.") + XCTAssertEqual(context.viewState.showRegistrationForm, true, "The username and password section should be shown.") + XCTAssertEqual(context.viewState.showSSOButtons, true, "The SSO buttons should be shown.") + } + + func testBasicServer() { + // Given a basic server example.com that only supports password registration. + let address = "https://example.com" + + // When updating the view model with the server. + viewModel.update(homeserverAddress: address, ssoIdentityProviders: []) + + // 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.") + XCTAssertEqual(context.viewState.showRegistrationForm, true, "The username and password section should be shown.") + XCTAssertEqual(context.viewState.showSSOButtons, false, "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, 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 testUsernameError() async { + // 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.") + XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown.") + XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid if there is no error.") + + // When displaying the error as a username error. + let errorMessage = "Username unavailable" + viewModel.displayError(.usernameUnavailable(errorMessage)) + + // Then the error should be shown in the footer. + XCTAssertEqual(context.viewState.usernameErrorMessage, errorMessage, "The error message should be stored.") + XCTAssertEqual(context.viewState.usernameFooterMessage, errorMessage, "The error message should replace the standard footer message.") + XCTAssertFalse(context.viewState.isUsernameValid, "The username should be invalid when an error is shown.") + + + // When clearing the error. + 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 + + // Then the error should be hidden again. + XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error anymore.") + XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown again.") + XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid when an error is cleared.") + } + + func testEmptyUsernameWithShortPassword() { + // 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.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + + // When entering a password of 7 characters without a username. + context.username = "" + context.password = "1234567" + + // Then the credentials should remain invalid. + XCTAssertFalse(context.viewState.isPasswordValid, "A 7-character password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + } + + func testEmptyUsernameWithValidPassword() { + // 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.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + + // When entering a password of 8 characters without a username. + context.username = "" + context.password = "12345678" + + // Then the password should be valid but the credentials should still be invalid. + XCTAssertTrue(context.viewState.isPasswordValid, "An 8-character password should be valid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + } + + func testValidUsernameWithEmptyPassword() { + // 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.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + + // When entering a username without a password. + context.username = "bob" + context.password = "" + + // Then the username should be valid but the credentials should still be invalid. + XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.") + XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid when there is no error.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + } + + func testUsernameErrorWithValidPassword() { + // 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.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + + // When entering a username and password and encountering a username error + context.username = "bob" + context.password = "12345678" + + let errorMessage = "Username unavailable" + viewModel.displayError(.usernameUnavailable(errorMessage)) + + // Then the password should be valid but the credentials should still be invalid. + XCTAssertTrue(context.viewState.isPasswordValid, "An 8-character password should be valid.") + XCTAssertFalse(context.viewState.isUsernameValid, "The username should be invalid when an error is shown.") + 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.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + 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.isPasswordValid, "An 8-character password should be valid.") + XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid when there is no error.") + XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid when the username and password are valid.") + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift new file mode 100644 index 000000000..a9f8efb99 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -0,0 +1,198 @@ +// +// 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 + +@available(iOS 14.0, *) +struct AuthenticationRegistrationScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: AuthenticationRegistrationViewModel.Context + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + .padding(.bottom, 36) + + serverInfo + .padding(.leading, 12) + + Divider() + .padding(.vertical, 21) + + if viewModel.viewState.showRegistrationForm { + registrationForm + } + + if viewModel.viewState.showRegistrationForm && viewModel.viewState.showSSOButtons { + Text(VectorL10n.or) + .foregroundColor(theme.colors.secondaryContent) + .padding(.top, 16) + } + + if viewModel.viewState.showSSOButtons { + ssoButtons + .padding(.top, 16) + } + + } + .frame(maxWidth: OnboardingMetrics.maxContentWidth) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .accentColor(theme.colors.accent) + .background(theme.colors.background.ignoresSafeArea()) + .alert(item: $viewModel.alertInfo) { $0.alert } + } + + /// The header containing the icon, title and message. + var header: some View { + VStack(spacing: 8) { + Image(Asset.Images.onboardingCongratulationsIcon.name) + .resizable() + .renderingMode(.template) + .foregroundColor(theme.colors.accent) + .frame(width: 90, height: 90) + .background(Circle().foregroundColor(.white).padding(2)) + .padding(.bottom, 8) + .accessibilityHidden(true) + + Text(VectorL10n.authenticationRegistrationTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + + Text(VectorL10n.authenticationRegistrationMessage) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + } + } + + /// 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)) + } + } + } + } + + /// The form with text fields for username and password, along with a submit button. + var registrationForm: some View { + VStack(spacing: 21) { + RoundedBorderTextField(title: nil, + placeHolder: VectorL10n.authenticationRegistrationUsername, + text: $viewModel.username, + footerText: viewModel.viewState.usernameFooterMessage, + isError: viewModel.viewState.hasEditedUsername && !viewModel.viewState.isUsernameValid, + isFirstResponder: false, + configuration: UIKitTextInputConfiguration(returnKeyType: .default, + autocapitalizationType: .none, + autocorrectionType: .no), + onEditingChanged: { validateUsername(isEditing: $0) }) + .onChange(of: viewModel.username) { _ in viewModel.send(viewAction: .clearUsernameError) } + .accessibilityIdentifier("usernameTextField") + + RoundedBorderTextField(title: nil, + placeHolder: VectorL10n.authPasswordPlaceholder, + text: $viewModel.password, + footerText: VectorL10n.authenticationRegistrationPasswordFooter, + isError: viewModel.viewState.hasEditedPassword && !viewModel.viewState.isPasswordValid, + isFirstResponder: false, + configuration: UIKitTextInputConfiguration(isSecureTextEntry: true), + onEditingChanged: { validatePassword(isEditing: $0) }) + .accessibilityIdentifier("passwordTextField") + + Button { viewModel.send(viewAction: .next) } label: { + 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") + } + } + } + + /// Validates the username when the text field ends editing. + func validateUsername(isEditing: Bool) { + guard !isEditing, !viewModel.username.isEmpty else { return } + viewModel.send(viewAction: .validateUsername) + } + + /// Enables password validation the first time the user finishes editing the password text field. + func validatePassword(isEditing: Bool) { + guard !viewModel.viewState.hasEditedPassword, !isEditing else { return } + viewModel.send(viewAction: .enablePasswordValidation) + } +} + +// MARK: - Previews + +@available(iOS 15.0, *) +struct AuthenticationRegistration_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationRegistrationScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift new file mode 100644 index 000000000..743685900 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift @@ -0,0 +1,84 @@ +// +// 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 + +@available(iOS 14.0, *) +/// An button that displays the icon and name of an SSO provider. +struct AuthenticationSSOButton: View { + + // MARK: - Constants + + enum Brand: String { + case apple, facebook, github, gitlab, google, twitter + } + + // MARK: - Private + + @Environment(\.theme) private var theme + + // MARK: - Public + + let provider: SSOIdentityProvider + let action: () -> Void + + // MARK: - Views + + var body: some View { + Button(action: action) { + HStack { + icon + .frame(maxWidth: .infinity, alignment: .leading) + + Text(VectorL10n.socialLoginButtonTitleContinue(provider.name)) + .foregroundColor(theme.colors.primaryContent) + .multilineTextAlignment(.center) + .layoutPriority(1) + + icon + .frame(minWidth: 0, maxWidth: .infinity, alignment: .trailing) + .opacity(0) + } + .frame(maxWidth: .infinity) + .contentShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(SecondaryActionButtonStyle(customColor: theme.colors.quinaryContent)) + } + + @ViewBuilder + var icon: some View { + switch provider.brand { + case Brand.apple.rawValue: + Image(Asset.Images.authenticationSsoIconApple.name) + .renderingMode(.template) + .foregroundColor(theme.colors.primaryContent) + case Brand.facebook.rawValue: + Image(Asset.Images.authenticationSsoIconFacebook.name) + case Brand.github.rawValue: + Image(Asset.Images.authenticationSsoIconGithub.name) + .renderingMode(.template) + .foregroundColor(theme.colors.primaryContent) + case Brand.gitlab.rawValue: + Image(Asset.Images.authenticationSsoIconGitlab.name) + case Brand.google.rawValue: + Image(Asset.Images.authenticationSsoIconGoogle.name) + case Brand.twitter.rawValue: + Image(Asset.Images.authenticationSsoIconTwitter.name) + default: + EmptyView() + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift new file mode 100644 index 000000000..fc1c3602c --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift @@ -0,0 +1,77 @@ +// +// 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 AuthenticationServerSelectionViewModelResult { + /// The user would like to use the homeserver at the given address. + case next(homeserverAddress: String) + /// Dismiss the view without using the entered address. + case dismiss +} + +// MARK: View + +struct AuthenticationServerSelectionViewState: BindableState { + /// View state that can be bound to from SwiftUI. + var bindings: AuthenticationServerSelectionBindings + /// An error message to be shown in the text field footer. + var footerErrorMessage: String? + /// Whether the screen is presented modally or within a navigation stack. + var hasModalPresentation: Bool + + /// The message to show in the text field footer. + var footerMessage: String { + footerErrorMessage ?? VectorL10n.authenticationServerSelectionServerFooter + } + + /// The text field is showing an error. + var isShowingFooterError: Bool { + footerErrorMessage != nil + } + + /// Whether it is possible to continue by tapping the Next button. + var hasValidationError: Bool { + bindings.homeserverAddress.isEmpty || isShowingFooterError + } +} + +struct AuthenticationServerSelectionBindings: BindableState { + /// The homeserver address input by the user. + var homeserverAddress: String + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? +} + +enum AuthenticationServerSelectionViewAction { + /// The user would like to use the homeserver at the input address. + case next + /// Dismiss the view without using the entered address. + case dismiss + /// Open the EMS link. + case getInTouch + /// Clear any errors shown in the text field footer. + case clearFooterError +} + +enum AuthenticationServerSelectionErrorType: Hashable { + /// An error message to be shown in the text field footer. + case footerMessage(String) + /// An error occurred when trying to open the EMS link + case openURLAlert +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift new file mode 100644 index 000000000..01848a454 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift @@ -0,0 +1,84 @@ +// +// 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 + +@available(iOS 14, *) +typealias AuthenticationServerSelectionViewModelType = StateStoreViewModel +@available(iOS 14, *) +class AuthenticationServerSelectionViewModel: AuthenticationServerSelectionViewModelType, AuthenticationServerSelectionViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var completion: ((AuthenticationServerSelectionViewModelResult) -> Void)? + + // MARK: - Setup + + init(homeserverAddress: String, hasModalPresentation: Bool) { + let bindings = AuthenticationServerSelectionBindings(homeserverAddress: homeserverAddress) + super.init(initialViewState: AuthenticationServerSelectionViewState(bindings: bindings, + hasModalPresentation: hasModalPresentation)) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationServerSelectionViewAction) { + Task { + await MainActor.run { + switch viewAction { + case .next: + completion?(.next(homeserverAddress: state.bindings.homeserverAddress)) + case .dismiss: + completion?(.dismiss) + case .getInTouch: + getInTouch() + case .clearFooterError: + guard state.footerErrorMessage != nil else { return } + withAnimation { state.footerErrorMessage = nil } + } + } + } + } + + @MainActor func displayError(_ type: AuthenticationServerSelectionErrorType) { + switch type { + case .footerMessage(let message): + withAnimation { + state.footerErrorMessage = message + } + case .openURLAlert: + state.bindings.alertInfo = AlertInfo(id: .openURLAlert, title: VectorL10n.roomMessageUnableOpenLinkErrorMessage) + } + } + + // MARK: - Private + + /// Opens the EMS link in the user's browser. + @MainActor private func getInTouch() { + let url = BuildSettings.onboardingHostYourOwnServerLink + + UIApplication.shared.open(url) { [weak self] success in + guard !success, let self = self else { return } + self.displayError(.openURLAlert) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModelProtocol.swift new file mode 100644 index 000000000..4ff73e1c4 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModelProtocol.swift @@ -0,0 +1,27 @@ +// +// 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 AuthenticationServerSelectionViewModelProtocol { + + @MainActor var completion: ((AuthenticationServerSelectionViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: AuthenticationServerSelectionViewModelType.Context { get } + + /// Displays an error to the user. + @MainActor func displayError(_ type: AuthenticationServerSelectionErrorType) +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift new file mode 100644 index 000000000..39bd0edb9 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift @@ -0,0 +1,133 @@ +// +// 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 + +@available(iOS 14.0, *) +struct AuthenticationServerSelectionCoordinatorParameters { + let authenticationService: AuthenticationService + /// Whether the screen is presented modally or within a navigation stack. + let hasModalPresentation: Bool +} + +enum AuthenticationServerSelectionCoordinatorResult { + case updated(loginFlow: LoginFlowResult, registrationResult: RegistrationResult) + case dismiss +} + +@available(iOS 14.0, *) +final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationServerSelectionCoordinatorParameters + private let authenticationServerSelectionHostingController: VectorHostingController + private var authenticationServerSelectionViewModel: AuthenticationServerSelectionViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + /// The authentication service that will be updated with the new selection. + var authenticationService: AuthenticationService { parameters.authenticationService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + @MainActor var completion: ((AuthenticationServerSelectionCoordinatorResult) -> Void)? + + // MARK: - Setup + + @MainActor init(parameters: AuthenticationServerSelectionCoordinatorParameters) { + self.parameters = parameters + + let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: parameters.authenticationService.homeserverAddress, + hasModalPresentation: parameters.hasModalPresentation) + let view = AuthenticationServerSelectionScreen(viewModel: viewModel.context) + authenticationServerSelectionViewModel = viewModel + authenticationServerSelectionHostingController = VectorHostingController(rootView: view) + authenticationServerSelectionHostingController.vc_removeBackTitle() + authenticationServerSelectionHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationServerSelectionHostingController) + } + + // MARK: - Public + + func start() { + Task { + await MainActor.run { + MXLog.debug("[AuthenticationServerSelectionCoordinator] did start.") + authenticationServerSelectionViewModel.completion = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationServerSelectionCoordinator] AuthenticationServerSelectionViewModel did complete with result: \(result).") + + switch result { + case .next(let homeserverAddress): + self.useHomeserver(homeserverAddress) + case .dismiss: + self.completion?(.dismiss) + } + } + } + } + } + + func toPresentable() -> UIViewController { + return self.authenticationServerSelectionHostingController + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + /// - Parameters: + /// - label: The label to show on the indicator. + /// - isInteractionBlocking: Whether the indicator should block any user interaction. + @MainActor private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) { + loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking)) + } + + /// Hide the currently displayed activity indicator. + @MainActor private func stopLoading() { + loadingIndicator = nil + } + + /// 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.sanitize(homeserverAddress) + + Task { + do { + let (loginFlow, registrationResult) = try await authenticationService.refreshServer(homeserverAddress: homeserverAddress) + stopLoading() + + completion?(.updated(loginFlow: loginFlow, registrationResult: registrationResult)) + } catch { + stopLoading() + + // Show the MXError message if possible otherwise use a generic server error + let message = MXError(nsError: error)?.error ?? VectorL10n.authenticationServerSelectionGenericError + authenticationServerSelectionViewModel.displayError(.footerMessage(message)) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift new file mode 100644 index 000000000..8cc3c425b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift @@ -0,0 +1,66 @@ +// +// 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. +@available(iOS 14.0, *) +enum MockAuthenticationServerSelectionScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case matrix + case emptyAddress + case invalidAddress + case nonModal + + /// The associated screen + var screenType: Any.Type { + AuthenticationServerSelectionScreen.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationServerSelectionViewModel + switch self { + case .matrix: + viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "https://matrix.org", + hasModalPresentation: true) + case .emptyAddress: + viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "", + hasModalPresentation: true) + case .invalidAddress: + viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "thisisbad", + hasModalPresentation: true) + Task { + await MainActor.run { + viewModel.displayError(.footerMessage(VectorL10n.errorCommonMessage)) + } + } + case .nonModal: + viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "https://matrix.org", + hasModalPresentation: false) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], AnyView(AuthenticationServerSelectionScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift new file mode 100644 index 000000000..3ca45bed6 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift @@ -0,0 +1,87 @@ +// +// 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 + +@available(iOS 14.0, *) +class AuthenticationServerSelectionUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockAuthenticationServerSelectionScreenState.self + } + + override class func createTest() -> MockScreenTest { + return AuthenticationServerSelectionUITests(selector: #selector(verifyAuthenticationServerSelectionScreen)) + } + + func verifyAuthenticationServerSelectionScreen() throws { + guard let screenState = screenState as? MockAuthenticationServerSelectionScreenState else { fatalError("no screen") } + switch screenState { + case .matrix: + verifyNormalState() + case .emptyAddress: + verifyEmptyAddress() + case .invalidAddress: + verifyInvalidAddress() + case .nonModal: + verifyNonModalPresentation() + } + } + + func verifyNormalState() { + let serverTextField = app.textFields.element + XCTAssertEqual(serverTextField.value as? String, "https://matrix.org", "The server shown should be matrix.org with the https scheme.") + + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should always be shown.") + XCTAssertTrue(nextButton.isEnabled, "The next button should be enabled when there is an address.") + + let textFieldFooter = app.staticTexts["addressTextField"] + XCTAssertTrue(textFieldFooter.exists) + XCTAssertEqual(textFieldFooter.label, VectorL10n.authenticationServerSelectionServerFooter) + + let dismissButton = app.buttons["dismissButton"] + XCTAssertTrue(dismissButton.exists, "The dismiss button should be shown during modal presentation.") + } + + func verifyEmptyAddress() { + let serverTextField = app.textFields.element + XCTAssertEqual(serverTextField.value as? String, "", "The text field should be empty in this state.") + + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should always be shown.") + XCTAssertFalse(nextButton.isEnabled, "The next button should be disabled when the address is empty.") + } + + func verifyInvalidAddress() { + let serverTextField = app.textFields.element + XCTAssertEqual(serverTextField.value as? String, "thisisbad", "The text field should show the entered server.") + + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should always be shown.") + XCTAssertFalse(nextButton.isEnabled, "The next button should be disabled when there is an error.") + + let textFieldFooter = app.staticTexts["addressTextField"] + XCTAssertTrue(textFieldFooter.exists) + XCTAssertEqual(textFieldFooter.label, VectorL10n.errorCommonMessage) + } + + func verifyNonModalPresentation() { + let dismissButton = app.buttons["dismissButton"] + XCTAssertFalse(dismissButton.exists, "The dismiss button should be hidden when not in modal presentation.") + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift new file mode 100644 index 000000000..ccc3b3095 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift @@ -0,0 +1,59 @@ +// +// 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 + +@available(iOS 14.0, *) +class AuthenticationServerSelectionViewModelTests: XCTestCase { + private enum Constants { + static let counterInitialValue = 0 + } + + var viewModel: AuthenticationServerSelectionViewModelProtocol! + var context: AuthenticationServerSelectionViewModelType.Context! + + override func setUp() async throws { + viewModel = await AuthenticationServerSelectionViewModel(homeserverAddress: "", hasModalPresentation: true) + context = await viewModel.context + } + + @MainActor func testErrorMessage() async { + // 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.") + + // When an error occurs. + let message = "Unable to contact server." + viewModel.displayError(.footerMessage(message)) + + // Then the footer should now be showing an error. + XCTAssertEqual(context.viewState.footerErrorMessage, message, "The error message should be stored.") + XCTAssertEqual(context.viewState.footerMessage, message, "The error message should be shown.") + + // And when clearing the error. + 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 + + // Then the error message should now be removed. + XCTAssertNil(context.viewState.footerErrorMessage, "The error message should have been cleared.") + XCTAssertEqual(context.viewState.footerMessage, VectorL10n.authenticationServerSelectionServerFooter, "The standard footer message should be shown again.") + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift new file mode 100644 index 000000000..cf3c3efd9 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift @@ -0,0 +1,190 @@ +// +// 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 + +@available(iOS 14.0, *) +struct AuthenticationServerSelectionScreen: View { + + enum Constants { + static let textFieldID = "textFieldID" + } + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + /// The scroll view proxy can be stored here for use in other methods. + @State private var scrollView: ScrollViewProxy? + + // MARK: Public + + @ObservedObject var viewModel: AuthenticationServerSelectionViewModel.Context + + // MARK: Views + + var body: some View { + GeometryReader { geometry in + ScrollView { + ScrollViewReader { reader in + VStack(spacing: 0) { + header + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + .padding(.bottom, 36) + + serverForm + + Spacer() + + emsBanner + .padding(.vertical, 16) + } + .frame(maxWidth: OnboardingMetrics.maxContentWidth, minHeight: geometry.size.height) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .onAppear { scrollView = reader } + } + } + } + .ignoresSafeArea(.keyboard) + .background(theme.colors.background.ignoresSafeArea()) + .accentColor(theme.colors.accent) + .alert(item: $viewModel.alertInfo) { $0.alert } + .toolbar { toolbar } + } + + /// The title, message and icon at the top of the screen. + var header: some View { + VStack(spacing: 8) { + Image(Asset.Images.authenticationServerSelectionIcon.name) + .resizable() + .renderingMode(.template) + .foregroundColor(theme.colors.accent) + .frame(width: 90, height: 90) + .background(Circle().foregroundColor(.white).padding(4)) + .padding(.bottom, 8) + .accessibilityHidden(true) + + Text(VectorL10n.authenticationServerSelectionTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + + Text(VectorL10n.authenticationServerSelectionMessage) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + } + } + + /// The text field and submit button where the user enters a server URL. + var serverForm: some View { + VStack(alignment: .leading, spacing: 12) { + RoundedBorderTextField(title: nil, + placeHolder: VectorL10n.authenticationServerSelectionServerUrl, + text: $viewModel.homeserverAddress, + footerText: viewModel.viewState.footerMessage, + isError: viewModel.viewState.isShowingFooterError, + isFirstResponder: false, + configuration: UIKitTextInputConfiguration(keyboardType: .URL, + returnKeyType: .default, + autocapitalizationType: .none, + autocorrectionType: .no), + onTextChanged: nil, + onEditingChanged: textFieldEditingChangeHandler) + .onChange(of: viewModel.homeserverAddress) { _ in viewModel.send(viewAction: .clearFooterError) } + .id(Constants.textFieldID) + .accessibilityIdentifier("addressTextField") + + Button { viewModel.send(viewAction: .next) } label: { + Text(VectorL10n.next) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(viewModel.viewState.hasValidationError) + .accessibilityIdentifier("nextButton") + } + } + + /// A banner shown beneath the server form with information about hosting your own server. + var emsBanner: some View { + VStack(spacing: 12) { + Image(Asset.Images.authenticationServerSelectionEmsLogo.name) + .padding(.top, 8) + .accessibilityHidden(true) + + Text(VectorL10n.authenticationServerSelectionEmsTitle) + .font(theme.fonts.title3SB) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + + VStack(spacing: 2) { + Text(VectorL10n.authenticationServerSelectionEmsMessage) + .font(theme.fonts.callout) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + Text(VectorL10n.authenticationServerSelectionEmsLink) + .font(theme.fonts.callout) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + } + .padding(.bottom, 4) + .accessibilityElement(children: .combine) + + Button { viewModel.send(viewAction: .getInTouch) } label: { + Text(VectorL10n.authenticationServerSelectionEmsButton) + .font(theme.fonts.body) + } + .buttonStyle(PrimaryActionButtonStyle(customColor: theme.colors.ems)) + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 9).foregroundColor(theme.colors.system)) + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .confirmationAction) { + if viewModel.viewState.hasModalPresentation { + Button { viewModel.send(viewAction: .dismiss) } label: { + Image(Asset.Images.spacesModalClose.name) + } + .accessibilityLabel(VectorL10n.close) + .accessibilityIdentifier("dismissButton") + } + } + } + + /// Ensures the textfield is on screen when editing starts. + /// + /// This is required due to the `.ignoresSafeArea(.keyboard)` modifier which preserves + /// the spacing between the Next button and the EMS banner when the keyboard appears. + func textFieldEditingChangeHandler(isEditing: Bool) { + guard isEditing else { return } + withAnimation { scrollView?.scrollTo(Constants.textFieldID) } + } +} + +// MARK: - Previews + +@available(iOS 15.0, *) +struct AuthenticationServerSelection_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationServerSelectionScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 3afc87705..ca153fdbc 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -21,6 +21,8 @@ import Foundation enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ MockLiveLocationSharingViewerScreenState.self, + MockAuthenticationRegistrationScreenState.self, + MockAuthenticationServerSelectionScreenState.self, MockOnboardingCelebrationScreenState.self, MockOnboardingAvatarScreenState.self, MockOnboardingDisplayNameScreenState.self, diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift index 84c75089a..c9f3d73c3 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -58,6 +58,7 @@ struct RoundedBorderTextField: View { .font(theme.fonts.callout) .foregroundColor(theme.colors.tertiaryContent) .lineLimit(1) + .accessibilityHidden(true) } if isEnabled { ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in @@ -66,22 +67,24 @@ struct RoundedBorderTextField: View { }) .makeFirstResponder(isFirstResponder) .showClearButton(text: $text) - .onChange(of: text, perform: { newText in + .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, perform: { newText in + .onChange(of: text) { newText in onTextChanged?(newText) - }) + } .frame(height: 30) .allowsHitTesting(false) .opacity(0.5) + .accessibilityLabel(text.isEmpty ? placeHolder : "") } } .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: text.isEmpty ? 8 : 0)) diff --git a/changelog.d/5648.wip b/changelog.d/5648.wip new file mode 100644 index 000000000..fd0fa5904 --- /dev/null +++ b/changelog.d/5648.wip @@ -0,0 +1 @@ +Authentication: Begin implementing authentication flow with a Service, Registration screen and Server Selection screen.