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.