Add Authentication Flow WIP.

- Add Registration Screen.
- Add Server Selection Screen.
- Rename AuthenticationCoordinator to LegacyAuthenticationCoordinator.
- Add AuthenticationService and RegistrationWizard.
- Async extensions.
- Add global white and EMS colors to the themes.
- Add tests for server selection and registration screens.
- Accessibility and iPad layout tweaks.
- Remove MainActor from Auth Coordinators/VMs/Views.
(It broke the protocol conformances so now the methods and properties are marked individually.)
This commit is contained in:
Doug
2022-04-14 11:06:12 +01:00
committed by Doug
parent 60cff1d6bc
commit 282fe5c27e
53 changed files with 2870 additions and 66 deletions

View File

@@ -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

View File

@@ -46,5 +46,9 @@ public struct ColorValues: Colors {
public let background: UIColor
public let white: UIColor
public let ems: UIColor
public let namesAndAvatars: [UIColor]
}

View File

@@ -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 }

View File

@@ -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) })
}
}

View File

@@ -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),

View File

@@ -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),

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "authentication_server_selection_ems_logo.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,7 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M25 50C38.8071 50 50 38.8071 50 25C50 11.1929 38.8071 0 25 0C11.1929 0 0 11.1929 0 25C0 38.8071 11.1929 50 25 50Z" fill="#7E69FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.4296 11.649C20.4296 10.6399 21.2493 9.82187 22.2605 9.82187C29.1144 9.82187 34.6705 15.3664 34.6705 22.206C34.6705 23.2151 33.8507 24.0332 32.8395 24.0332C31.8283 24.0332 31.0085 23.2151 31.0085 22.206C31.0085 17.3847 27.0919 13.4762 22.2605 13.4762C21.2493 13.4762 20.4296 12.6582 20.4296 11.649Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.3473 20.3788C39.3585 20.3788 40.1783 21.1968 40.1783 22.206C40.1783 29.0455 34.6222 34.5901 27.7684 34.5901C26.7571 34.5901 25.9374 33.7721 25.9374 32.7629C25.9374 31.7538 26.7571 30.9358 27.7684 30.9358C32.5997 30.9358 36.5163 27.0273 36.5163 22.206C36.5163 21.1968 37.3361 20.3788 38.3473 20.3788Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.5995 38.3523C29.5995 39.3614 28.7797 40.1795 27.7685 40.1795C20.9147 40.1795 15.3586 34.6349 15.3586 27.7953C15.3586 26.7862 16.1783 25.9681 17.1896 25.9681C18.2008 25.9681 19.0205 26.7862 19.0205 27.7953C19.0205 32.6167 22.9371 36.5251 27.7685 36.5251C28.7797 36.5251 29.5995 37.3432 29.5995 38.3523Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.6532 29.6223C10.642 29.6223 9.82222 28.8043 9.82221 27.7951C9.82221 20.9556 15.3783 15.411 22.2321 15.411C23.2434 15.411 24.0631 16.229 24.0631 17.2382C24.0631 18.2473 23.2434 19.0653 22.2321 19.0653C17.4008 19.0653 13.4842 22.9738 13.4842 27.7951C13.4842 28.8043 12.6644 29.6223 11.6532 29.6223Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "authentication_server_selection_icon.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 70 70" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M35,0C54.317,0 70,15.683 70,35C70,54.317 54.317,70 35,70C15.683,70 0,54.317 0,35C0,15.683 15.683,0 35,0ZM47.25,36.75L22.75,36.75C20.825,36.75 19.25,38.325 19.25,40.25L19.25,47.25C19.25,49.175 20.825,50.75 22.75,50.75L47.25,50.75C49.175,50.75 50.75,49.175 50.75,47.25L50.75,40.25C50.75,38.325 49.175,36.75 47.25,36.75ZM26.25,47.25C24.325,47.25 22.75,45.675 22.75,43.75C22.75,41.825 24.325,40.25 26.25,40.25C28.175,40.25 29.75,41.825 29.75,43.75C29.75,45.675 28.175,47.25 26.25,47.25ZM47.25,19.25L22.75,19.25C20.825,19.25 19.25,20.825 19.25,22.75L19.25,29.75C19.25,31.675 20.825,33.25 22.75,33.25L47.25,33.25C49.175,33.25 50.75,31.675 50.75,29.75L50.75,22.75C50.75,20.825 49.175,19.25 47.25,19.25ZM26.25,29.75C24.325,29.75 22.75,28.175 22.75,26.25C22.75,24.325 24.325,22.75 26.25,22.75C28.175,22.75 29.75,24.325 29.75,26.25C29.75,28.175 28.175,29.75 26.25,29.75Z" style="fill:rgb(13,189,139);"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "authentication_sso_apple.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9721 2.2793C16.0601 3.42765 15.6969 4.56506 14.9598 5.44997C14.2446 6.33947 13.161 6.85183 12.0197 6.84008C11.9471 5.72478 12.3208 4.62638 13.0585 3.78679C13.8056 2.93583 14.8459 2.39758 15.9721 2.2793ZM19.5816 9.02677C18.2687 9.83389 17.4609 11.2573 17.441 12.7983C17.4429 14.5417 18.487 16.1151 20.0927 16.7942C19.7839 17.7974 19.3184 18.7454 18.7134 19.6033C17.901 20.8185 17.0492 22.0058 15.6973 22.0277C15.0543 22.0426 14.6203 21.8577 14.168 21.665C13.6963 21.4641 13.2047 21.2547 12.4354 21.2547C11.6196 21.2547 11.106 21.4708 10.6107 21.6793C10.1826 21.8594 9.76816 22.0338 9.1841 22.0581C7.89658 22.1057 6.91259 20.761 6.07065 19.5571C4.38785 17.0986 3.07748 12.6286 4.83421 9.5871C5.65915 8.10472 7.20157 7.16405 8.89715 7.10927C9.62738 7.09424 10.3281 7.3757 10.9424 7.62246C11.4122 7.81117 11.8315 7.97959 12.1749 7.97959C12.4767 7.97959 12.8843 7.81782 13.3593 7.62929C14.1076 7.33231 15.0232 6.96892 15.9562 7.06686C17.406 7.11222 18.7496 7.83856 19.5816 9.02677Z" fill="#17191C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "authentication_sso_facebook.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_2258_29320" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="1" width="22" height="22">
<path d="M1.50146 1.5H22.5002V22.3716H1.50146V1.5Z" fill="white"/>
</mask>
<g mask="url(#mask0_2258_29320)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.4999 11.999C22.4999 6.20003 17.7989 1.49902 11.9999 1.49902C6.20093 1.49902 1.49992 6.20003 1.49992 11.999C1.49992 17.2399 5.33962 21.5838 10.3593 22.3715V15.0342H7.69328V11.999H10.3593V9.68574C10.3593 7.05418 11.9269 5.60059 14.3253 5.60059C15.4741 5.60059 16.6757 5.80566 16.6757 5.80566V8.38965H15.3517C14.0473 8.38965 13.6405 9.19903 13.6405 10.0294V11.999H16.5527L16.0871 15.0342H13.6405V22.3715C18.6602 21.5838 22.4999 17.2399 22.4999 11.999Z" fill="#1877F2"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0871 15.0342L16.5527 11.999H13.6405V10.0294C13.6405 9.19903 14.0473 8.38965 15.3517 8.38965H16.6757V5.80566C16.6757 5.80566 15.4741 5.60059 14.3253 5.60059C11.9269 5.60059 10.3593 7.05418 10.3593 9.68574V11.999H7.69328V15.0342H10.3593V22.3715C10.8939 22.4553 11.4418 22.499 11.9999 22.499C12.5581 22.499 13.106 22.4553 13.6405 22.3715V15.0342H16.0871Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "authentication_sso_github.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.4421 7.10595C19.5702 5.6121 18.3876 4.42941 16.8939 3.55763C15.3999 2.6858 13.769 2.25 11.9999 2.25C10.231 2.25 8.59957 2.68594 7.10595 3.55763C5.6121 4.42937 4.4295 5.6121 3.55763 7.10595C2.68589 8.59975 2.25 10.231 2.25 11.9996C2.25 14.1242 2.86984 16.0346 4.10984 17.7315C5.34971 19.4284 6.95143 20.6027 8.91488 21.2543C9.14343 21.2967 9.31262 21.2669 9.42262 21.1656C9.53268 21.0641 9.58763 20.937 9.58763 20.7848C9.58763 20.7594 9.58546 20.531 9.58123 20.0993C9.57687 19.6676 9.57483 19.291 9.57483 18.9696L9.28283 19.0202C9.09665 19.0543 8.86179 19.0687 8.57823 19.0646C8.29481 19.0607 8.00059 19.031 7.69595 18.9757C7.39119 18.921 7.10773 18.794 6.84534 18.5952C6.58308 18.3963 6.39691 18.1359 6.28686 17.8145L6.15991 17.5224C6.07529 17.3279 5.94207 17.1118 5.76008 16.875C5.57808 16.638 5.39404 16.4773 5.20786 16.3927L5.11898 16.329C5.05975 16.2867 5.00479 16.2357 4.95397 16.1766C4.90319 16.1174 4.86517 16.0582 4.83978 15.9988C4.81435 15.9395 4.83542 15.8908 4.90323 15.8526C4.97104 15.8144 5.09359 15.7959 5.2714 15.7959L5.52521 15.8338C5.69449 15.8677 5.90388 15.969 6.15364 16.1384C6.40326 16.3076 6.60847 16.5277 6.7693 16.7984C6.96406 17.1455 7.1987 17.4099 7.4739 17.5919C7.74887 17.7739 8.02611 17.8648 8.30535 17.8648C8.58459 17.8648 8.82577 17.8436 9.02897 17.8015C9.23196 17.7592 9.4224 17.6955 9.60022 17.611C9.67639 17.0437 9.88377 16.6079 10.2222 16.3032C9.73984 16.2526 9.30617 16.1762 8.92097 16.0747C8.53599 15.973 8.13816 15.8081 7.72775 15.5794C7.31711 15.3509 6.97646 15.0673 6.70572 14.7289C6.43492 14.3904 6.21269 13.9459 6.03932 13.3959C5.86586 12.8457 5.77911 12.211 5.77911 11.4916C5.77911 10.4674 6.11349 9.59577 6.78211 8.87633C6.4689 8.10628 6.49846 7.24303 6.8709 6.28668C7.11635 6.21042 7.48034 6.26765 7.9627 6.458C8.44515 6.64845 8.79838 6.81159 9.02275 6.94685C9.24712 7.08207 9.42689 7.19666 9.56233 7.28959C10.3496 7.06962 11.162 6.95961 11.9998 6.95961C12.8376 6.95961 13.6502 7.06962 14.4375 7.28959L14.9199 6.98505C15.2498 6.78184 15.6394 6.59562 16.0877 6.42634C16.5362 6.25715 16.8792 6.21055 17.1163 6.28681C17.497 7.24321 17.531 8.10641 17.2177 8.87646C17.8862 9.5959 18.2208 10.4677 18.2208 11.4918C18.2208 12.2111 18.1337 12.8478 17.9605 13.4023C17.7871 13.9568 17.5629 14.4008 17.288 14.7353C17.0127 15.0697 16.6699 15.3511 16.2594 15.5795C15.8489 15.808 15.451 15.973 15.066 16.0746C14.6808 16.1763 14.2472 16.2527 13.7648 16.3035C14.2048 16.6842 14.4248 17.2851 14.4248 18.106V20.7845C14.4248 20.9366 14.4777 21.0637 14.5836 21.1652C14.6894 21.2665 14.8564 21.2964 15.085 21.2539C17.0487 20.6024 18.6504 19.4281 19.8902 17.7311C21.1299 16.0343 21.75 14.1238 21.75 11.9993C21.7496 10.2309 21.3134 8.59975 20.4421 7.10595Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "authentication_sso_gitlab.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0004 20.3292L15.3166 10.1289H8.68915L12.0004 20.3292Z" fill="#E24329"/>
<path d="M4.04348 10.1289L3.03364 13.2279C2.94226 13.5093 3.04095 13.8199 3.28214 13.9953L11.9996 20.3292L4.04348 10.1289Z" fill="#FCA326"/>
<path d="M4.04248 10.1289H8.68727L6.68828 3.98572C6.58597 3.67143 6.1401 3.67143 6.03411 3.98572L4.04248 10.1289Z" fill="#E24329"/>
<path d="M19.9602 10.1289L20.9664 13.2279C21.0577 13.5093 20.9591 13.8199 20.7179 13.9953L11.9991 20.3292L19.9602 10.1289Z" fill="#FCA326"/>
<path d="M19.9616 10.1289H15.3168L17.3121 3.98572C17.4145 3.67143 17.8603 3.67143 17.9663 3.98572L19.9616 10.1289Z" fill="#E24329"/>
<path d="M11.9991 20.3292L15.3153 10.1289H19.9601L11.9991 20.3292Z" fill="#FC6D26"/>
<path d="M11.9985 20.3292L4.04248 10.1289H8.68727L11.9985 20.3292Z" fill="#FC6D26"/>
</svg>

After

Width:  |  Height:  |  Size: 905 B

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "authentication_sso_google.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.5011 12.2336C22.5011 11.3702 22.4296 10.7402 22.2749 10.0869H12.2154V13.9835H18.1201C18.0011 14.9519 17.3582 16.4102 15.9296 17.3902L15.9096 17.5206L19.0903 19.9354L19.3106 19.9569C21.3344 18.1252 22.5011 15.4302 22.5011 12.2336Z" fill="#4285F4"/>
<path d="M12.2147 22.4996C15.1075 22.4996 17.536 21.5662 19.3099 19.9562L15.9289 17.3895C15.0242 18.0078 13.8099 18.4395 12.2147 18.4395C9.38139 18.4395 6.97666 16.6079 6.11944 14.0762L5.99379 14.0866L2.68653 16.595L2.64328 16.7128C4.40516 20.1428 8.0242 22.4996 12.2147 22.4996Z" fill="#34A853"/>
<path d="M6.12019 14.0765C5.894 13.4232 5.7631 12.7231 5.7631 11.9998C5.7631 11.2764 5.894 10.5765 6.10829 9.92313L6.1023 9.78398L2.75358 7.23535L2.64402 7.28642C1.91786 8.70978 1.50119 10.3081 1.50119 11.9998C1.50119 13.6915 1.91786 15.2897 2.64402 16.7131L6.12019 14.0765Z" fill="#FBBC05"/>
<path d="M12.2148 5.55997C14.2266 5.55997 15.5837 6.41163 16.3576 7.12335L19.3814 4.23C17.5243 2.53834 15.1076 1.5 12.2148 1.5C8.02423 1.5 4.40517 3.85665 2.64328 7.28662L6.10756 9.92332C6.97668 7.39166 9.38143 5.55997 12.2148 5.55997Z" fill="#EB4335"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "authentication_sso_twitter.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.84156 21C6.41531 21 4.15363 20.2943 2.25 19.0767C3.86624 19.1813 6.71855 18.9308 8.49268 17.2386C5.82381 17.1161 4.6202 15.0692 4.4632 14.1945C4.68997 14.2819 5.77148 14.3869 6.382 14.142C3.31192 13.3722 2.84095 10.678 2.94561 9.85573C3.52125 10.2581 4.49809 10.3981 4.88185 10.3631C2.02109 8.31618 3.05027 5.23707 3.55613 4.57226C5.60912 7.4165 8.6859 9.01393 12.4923 9.10278C12.4205 8.78802 12.3826 8.46032 12.3826 8.12373C12.3826 5.70819 14.3351 3.75 16.7435 3.75C18.0019 3.75 19.1358 4.28457 19.9318 5.13963C20.7727 4.94258 22.0382 4.4813 22.6569 4.0824C22.3451 5.20208 21.3742 6.13612 20.7869 6.48231C20.7918 6.49408 20.7821 6.47048 20.7869 6.48231C21.3028 6.40428 22.6986 6.13603 23.25 5.76192C22.9773 6.39094 21.948 7.4368 21.1033 8.02232C21.2605 14.9535 15.9574 21 8.84156 21Z" fill="#1D9BF0"/>
</svg>

After

Width:  |  Height:  |  Size: 918 B

View File

@@ -20,6 +20,28 @@
"image_picker_action_files" = "Choose from files";
// MARK: Onboarding Authentication WIP
"authentication_registration_title" = "Create your account";
"authentication_registration_message" = "Well 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 cant 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";

View File

@@ -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")

View File

@@ -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")
}
/// Well 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 cant 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")

View File

@@ -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.

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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<Void, Error>
}
@available(iOS 14.0, *)
@MainActor extension RegistrationFlowHandling {
func executeRegistrationStep(step: @escaping (RegistrationWizard) async throws -> RegistrationResult) -> Task<Void, Error> {
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
}
}

View File

@@ -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<AuthenticationRegistrationErrorType>?
}
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
}

View File

@@ -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<AuthenticationRegistrationViewState,
Never,
AuthenticationRegistrationViewAction>
@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)
}
}
}

View File

@@ -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)
}

View File

@@ -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<Void, Error>? {
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 { }

View File

@@ -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))
)
}
}

View File

@@ -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)
}
}

View File

@@ -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.")
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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<AuthenticationServerSelectionErrorType>?
}
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
}

View File

@@ -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<AuthenticationServerSelectionViewState,
Never,
AuthenticationServerSelectionViewAction>
@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)
}
}
}

View File

@@ -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)
}

View File

@@ -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))
}
}
}
}

View File

@@ -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))
)
}
}

View File

@@ -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.")
}
}

View File

@@ -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.")
}
}

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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))

1
changelog.d/5648.wip Normal file
View File

@@ -0,0 +1 @@
Authentication: Begin implementing authentication flow with a Service, Registration screen and Server Selection screen.