Merge branch 'develop' into aringenbach/3526_user_pills

This commit is contained in:
aringenbach 2022-05-05 10:29:01 +02:00
commit 43d1e6b2b5
78 changed files with 3439 additions and 225 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,7 @@ public struct ColorValues: Colors {
public let background: 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,9 @@ public protocol Colors {
/// Background UI color
var background: 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,8 @@ public struct ColorSwiftUI: Colors {
public let background: Color
public var ems: Color
public let namesAndAvatars: [Color]
init(values: ColorValues) {
@ -62,6 +64,7 @@ public struct ColorSwiftUI: Colors {
tile = Color(values.tile)
navigation = Color(values.navigation)
background = Color(values.background)
ems = Color(values.ems)
namesAndAvatars = values.namesAndAvatars.map({ Color($0) })
}
}

View file

@ -33,6 +33,7 @@ public class DarkColors {
tile: UIColor(rgb:0x394049),
navigation: UIColor(rgb:0x21262C),
background: UIColor(rgb:0x15191E),
ems: UIColor(rgb: 0x7E69FF),
namesAndAvatars: [
UIColor(rgb:0x368BD6),
UIColor(rgb:0xAC3BA8),

View file

@ -34,6 +34,7 @@ public class LightColors {
tile: UIColor(rgb:0xF3F8FD),
navigation: UIColor(rgb:0xF4F6FA),
background: 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

@ -76,6 +76,7 @@
"error" = "Error";
"suggest" = "Suggest";
"edit" = "Edit";
"confirm" = "Confirm";
// Activities
"loading" = "Loading";
@ -2157,6 +2158,9 @@ Tap the + to start adding people.";
"location_sharing_live_timer_selector_short" = "for 15 minutes";
"location_sharing_live_timer_selector_medium" = "for 1 hour";
"location_sharing_live_timer_selector_long" = "for 8 hours";
"location_sharing_live_no_user_locations_error_title" = "No user locations available";
"location_sharing_live_stop_sharing_error" = "Fail to stop sharing location";
"location_sharing_live_stop_sharing_progress" = "Stop location sharing";
// MARK: - MatrixKit

View file

@ -0,0 +1,26 @@
//
// 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
extension MXBeaconInfoSummaryProtocol {
/// Indicate true if a beacon info summary can be displayed on a map
var isDisplayable: Bool {
return self.isActive && self.lastBeacon != nil
}
}

View file

@ -0,0 +1,35 @@
//
// 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
extension MXLocationService {
public func isSomeoneSharingDisplayableLocation(inRoomWithId roomId: String) -> Bool {
return self.getDisplayableBeaconInfoSummaries(inRoomWithId: roomId).isEmpty == false
}
/// Get beacon info summaries that can be shown on a map
func getDisplayableBeaconInfoSummaries(inRoomWithId roomId: String) -> [MXBeaconInfoSummaryProtocol] {
let liveBeaconInfoSummaries = self.getLiveBeaconInfoSummaries(inRoomWithId: roomId)
return liveBeaconInfoSummaries.filter { beaconInfoSummary in
return beaconInfoSummary.isDisplayable
}
}
}

View file

@ -0,0 +1,28 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
extension MXSession {
func avatarInput(for userId: String) -> AvatarInput {
let user = self.user(withUserId: userId)
return AvatarInput(mxContentUri: user?.avatarUrl,
matrixItemId: userId,
displayName: user?.displayname)
}
}

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

@ -847,6 +847,10 @@ public class VectorL10n: NSObject {
public static var collapse: String {
return VectorL10n.tr("Vector", "collapse")
}
/// Confirm
public static var confirm: String {
return VectorL10n.tr("Vector", "confirm")
}
/// Local Contacts
public static var contactLocalContacts: String {
return VectorL10n.tr("Vector", "contact_local_contacts")
@ -2799,10 +2803,22 @@ public class VectorL10n: NSObject {
public static var locationSharingLiveMapCalloutTitle: String {
return VectorL10n.tr("Vector", "location_sharing_live_map_callout_title")
}
/// No user locations available
public static var locationSharingLiveNoUserLocationsErrorTitle: String {
return VectorL10n.tr("Vector", "location_sharing_live_no_user_locations_error_title")
}
/// Share live location
public static var locationSharingLiveShareTitle: String {
return VectorL10n.tr("Vector", "location_sharing_live_share_title")
}
/// Fail to stop sharing location
public static var locationSharingLiveStopSharingError: String {
return VectorL10n.tr("Vector", "location_sharing_live_stop_sharing_error")
}
/// Stop location sharing
public static var locationSharingLiveStopSharingProgress: String {
return VectorL10n.tr("Vector", "location_sharing_live_stop_sharing_progress")
}
/// Live until %@
public static func locationSharingLiveTimerIncoming(_ p1: String) -> String {
return VectorL10n.tr("Vector", "location_sharing_live_timer_incoming", p1)

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
@ -30,10 +36,8 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
private let authenticationViewController: AuthenticationViewController
private var canPresentAdditionalScreens: Bool
private var isWaitingToPresentCompleteSecurity = false
private let crossSigningService = CrossSigningService()
private var verificationListener: SessionVerificationListener?
/// The password entered, for use when setting up cross-signing.
private var password: String?
/// The session created when successfully authenticated.
private var session: MXSession?
@ -52,7 +56,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
// MARK: - Setup
init(parameters: AuthenticationCoordinatorParameters) {
init(parameters: LegacyAuthenticationCoordinatorParameters) {
self.navigationRouter = parameters.navigationRouter
self.canPresentAdditionalScreens = parameters.canPresentAdditionalScreens
@ -121,7 +125,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
}
@ -141,119 +145,49 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
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("[AuthenticationCoordinator] 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("[AuthenticationCoordinator] 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("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap with password")
crossSigning.setup(withPassword: password) {
MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap succeeded")
self.authenticationDidComplete()
} failure: { error in
MXLog.error("[AuthenticationCoordinator] 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")
self.authenticationDidComplete()
} failure: { error in
MXLog.error("[AuthenticationCoordinator] 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("[AuthenticationCoordinator] sessionStateDidChange: Delaying presentCompleteSecurity during onboarding.")
self.isWaitingToPresentCompleteSecurity = true
return
}
MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Complete security")
self.presentCompleteSecurity()
default:
MXLog.debug("[AuthenticationCoordinator] 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)")
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self?.authenticationDidComplete()
}
} else {
authenticationDidComplete()
}
}
}
}
// MARK: - AuthenticationViewControllerDelegate
extension AuthenticationCoordinator: AuthenticationViewControllerDelegate {
extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate {
func authenticationViewController(_ authenticationViewController: AuthenticationViewController!, didLoginWith session: MXSession!, andPassword password: String!) {
registerSessionStateChangeNotification(for: session)
self.session = session
self.password = password
if canPresentAdditionalScreens {
showLoadingAnimation()
}
let verificationListener = SessionVerificationListener(session: session, password: password)
verificationListener.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case .needsVerification:
guard self.canPresentAdditionalScreens else {
MXLog.debug("[LegacyAuthenticationCoordinator] Delaying presentCompleteSecurity during onboarding.")
self.isWaitingToPresentCompleteSecurity = true
return
}
MXLog.debug("[LegacyAuthenticationCoordinator] Complete security")
self.presentCompleteSecurity()
case .authenticationIsComplete:
self.authenticationDidComplete()
}
}
verificationListener.start()
self.verificationListener = verificationListener
completion?(.didLogin(session: session, authenticationType: authenticationViewController.authType))
}
}
// 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 +204,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,354 @@
// 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
@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 var verificationListener: SessionVerificationListener?
/// 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 {
do {
let flow: AuthenticationFlow = initialScreen == .login ? .login : .registration
try await authenticationService.startFlow(flow, for: authenticationService.state.homeserver.address)
} catch {
MXLog.error("[AuthenticationCoordinator] start: Failed to start")
await MainActor.run { displayError(error) }
return
}
await MainActor.run {
switch initialScreen {
case .registration:
showRegistrationScreen()
case .selectServerForRegistration:
showServerSelectionScreen()
case .login:
showLoginScreen()
}
}
}
}
func toPresentable() -> UIViewController {
navigationRouter.toPresentable()
}
func presentPendingScreensIfNecessary() {
canPresentAdditionalScreens = true
showLoadingAnimation()
if isWaitingToPresentCompleteSecurity {
isWaitingToPresentCompleteSecurity = false
presentCompleteSecurity()
}
}
// MARK: - Private
/// Presents an alert on top of the navigation router, using the supplied error's `localizedDescription`.
@MainActor func displayError(_ error: Error) {
let alert = UIAlertController(title: VectorL10n.error,
message: error.localizedDescription,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: VectorL10n.ok, style: .default))
toPresentable().present(alert, animated: true)
}
// 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:
showRegistrationScreen()
case .dismiss:
MXLog.failure("[AuthenticationCoordinator] AuthenticationServerSelectionScreen is requesting dismiss when part of a stack.")
}
}
/// Shows the registration screen.
@MainActor private func showRegistrationScreen() {
MXLog.debug("[AuthenticationCoordinator] showRegistrationScreen")
let homeserver = authenticationService.state.homeserver
let parameters = AuthenticationRegistrationCoordinatorParameters(navigationRouter: navigationRouter,
authenticationService: authenticationService,
registrationFlow: homeserver.registrationFlow,
loginMode: homeserver.preferredLoginMode)
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 .completed(let result):
handleRegistrationResult(result)
}
}
/// 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 handleRegistrationResult(_ result: RegistrationResult) {
switch result {
case .success(let mxSession):
onSessionCreated(session: mxSession, isAccountCreated: true)
case .flowResponse(let flowResult):
// TODO
break
}
}
/// Handles the creation of a new session following on from a successful authentication.
func onSessionCreated(session: MXSession, isAccountCreated: Bool) {
self.session = session
// self.password = password
if canPresentAdditionalScreens {
showLoadingAnimation()
}
let verificationListener = SessionVerificationListener(session: session, password: password)
verificationListener.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case .needsVerification:
guard self.canPresentAdditionalScreens else {
MXLog.debug("[AuthenticationCoordinator] Delaying presentCompleteSecurity during onboarding.")
self.isWaitingToPresentCompleteSecurity = true
return
}
MXLog.debug("[AuthenticationCoordinator] Complete security")
self.presentCompleteSecurity()
case .authenticationIsComplete:
self.authenticationDidComplete()
}
}
verificationListener.start()
self.verificationListener = verificationListener
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)
}
}
// 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

@ -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,140 @@
//
// 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
/// An object that will listen for the cross-signing state of a new session
/// determining whether or not verification needs to be performed.
class SessionVerificationListener {
enum Result {
case needsVerification
case authenticationIsComplete
}
// MARK: - Properties
/// The completion handler called once the cross-signing state has been determined.
var completion: ((Result) -> Void)?
/// The session being used
private let session: MXSession
/// The session's password (if used), for boot-strapping the cross-signing.
private let password: String?
/// The cross-signing service.
private let crossSigningService = CrossSigningService()
// MARK: - Setup
/// Creates a new listener object.
/// - Parameter session: The session to listen to.
/// - Parameter password: The password used for the session (optional).
init(session: MXSession, password: String?) {
self.session = session
self.password = password
}
// MARK: - Public
/// Start listening for the cross-signing state of the supplied session.
func start() {
registerSessionStateChangeNotification(for: session)
}
// MARK: - Private
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("[SessionVerificationListener] 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("[SessionVerificationListener] 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("[SessionVerificationListener] sessionStateDidChange: Bootstrap with password")
crossSigning.setup(withPassword: password) {
MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Bootstrap succeeded")
self.completion?(.authenticationIsComplete)
} failure: { error in
MXLog.error("[SessionVerificationListener] sessionStateDidChange: Bootstrap failed. Error: \(error)")
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self.completion?(.authenticationIsComplete)
}
} else {
// Try to setup cross-signing without authentication parameters in case if a grace period is enabled
self.crossSigningService.setupCrossSigningWithoutAuthentication(for: session) {
MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Bootstrap succeeded without credentials")
self.completion?(.authenticationIsComplete)
} failure: { error in
MXLog.error("[SessionVerificationListener] sessionStateDidChange: Do not know how to bootstrap cross-signing. Skip it.")
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self.completion?(.authenticationIsComplete)
}
}
} else {
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self.completion?(.authenticationIsComplete)
}
case .crossSigningExists:
MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Needs verification")
self.completion?(.needsVerification)
default:
MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Nothing to do")
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self.completion?(.authenticationIsComplete)
}
} failure: { [weak self] error in
MXLog.error("[SessionVerificationListener] sessionStateDidChange: Fail to refresh crypto state with error: \(error)")
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
self?.completion?(.authenticationIsComplete)
}
} else {
completion?(.authenticationIsComplete)
}
}
}
}

View file

@ -1344,7 +1344,7 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
- (void)updateBeaconInfoSummaryWithEventId:(NSString *)eventId
{
MXBeaconInfoSummary *beaconInfoSummary = [self.mxSession.aggregations.beaconAggregations beaconInfoSummaryFor:eventId inRoomWithId:self.roomId];
id<MXBeaconInfoSummaryProtocol> beaconInfoSummary = [self.mxSession.aggregations.beaconAggregations beaconInfoSummaryFor:eventId inRoomWithId:self.roomId];
self.beaconInfoSummary = beaconInfoSummary;
}

View file

@ -3045,22 +3045,6 @@
[self promptUserToResendEvent:selectedEvent.eventId];
}
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellStopShareButtonPressed])
{
MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey];
if (selectedEvent)
{
// TODO: - Implement stop live location action
}
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellRetryShareButtonPressed])
{
MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey];
if (selectedEvent)
{
// TODO: - Implement retry live location action
}
}
}
#pragma mark - Clipboard

View file

@ -32,6 +32,7 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
private let userIndicatorStore: UserIndicatorStore
private var selectedEventId: String?
private var loadingCancel: UserIndicatorCancel?
private var locationSharingIndicatorCancel: UserIndicatorCancel? // Used for location sharing advertizements
private var roomDataSourceManager: MXKRoomDataSourceManager {
return MXKRoomDataSourceManager.sharedManager(forMatrixSession: self.parameters.session)
@ -248,6 +249,87 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
completion?()
}
private func showLiveLocationViewer() {
guard let roomId = self.roomId else {
return
}
self.showLiveLocationViewer(for: roomId)
}
private func showLiveLocationViewer(for roomId: String) {
guard let mxSession = self.mxSession, let navigationRouter = self.navigationRouter else {
return
}
guard mxSession.locationService.isSomeoneSharingDisplayableLocation(inRoomWithId: roomId) else {
return
}
let parameters = LiveLocationSharingViewerCoordinatorParameters(session: mxSession, roomId: roomId, navigationRouter: nil)
let coordinator = LiveLocationSharingViewerCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] in
guard let self = self, let coordinator = coordinator else {
return
}
self.navigationRouter?.dismissModule(animated: true, completion: nil)
self.remove(childCoordinator: coordinator)
}
add(childCoordinator: coordinator)
navigationRouter.present(coordinator, animated: true)
coordinator.start()
}
private func stopLiveLocationSharing(forBeaconInfoEventId beaconInfoEventId: String? = nil, inRoomWithId roomId: String) {
guard let session = self.mxSession else {
return
}
let errorHandler: (Error) -> Void = { error in
let viewController = self.roomViewController
viewController.errorPresenter.presentError(from: viewController, title: VectorL10n.error, message: VectorL10n.locationSharingLiveStopSharingError, animated: true) {
}
}
// TODO: Handle loading state on the banner by replacing stop button with a spinner
self.showLocationSharingIndicator(withMessage: VectorL10n.locationSharingLiveStopSharingProgress)
if let beaconInfoEventId = beaconInfoEventId {
session.locationService.stopUserLocationSharing(withBeaconInfoEventId: beaconInfoEventId, roomId: roomId) {
[weak self] response in
self?.hideLocationSharingIndicator()
switch response {
case .success:
break
case .failure(let error):
errorHandler(error)
}
}
} else {
session.locationService.stopUserLocationSharing(inRoomWithId: roomId) { [weak self] response in
self?.hideLocationSharingIndicator()
switch response {
case .success:
break
case .failure(let error):
errorHandler(error)
}
}
}
}
private func showLocationCoordinatorWithEvent(_ event: MXEvent, bubbleData: MXKRoomBubbleCellDataStoring) {
guard #available(iOS 14.0, *) else {
return
@ -371,6 +453,19 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
loadingCancel?()
loadingCancel = nil
}
private func showLocationSharingIndicator(withMessage message: String) {
guard locationSharingIndicatorCancel == nil else {
return
}
locationSharingIndicatorCancel = userIndicatorStore.present(type: .loading(label: message, isInteractionBlocking: false))
}
private func hideLocationSharingIndicator() {
locationSharingIndicatorCancel?()
locationSharingIndicatorCancel = nil
}
}
// MARK: - RoomIdentifiable
@ -449,6 +544,15 @@ extension RoomCoordinator: RoomViewControllerDelegate {
showLocationCoordinatorWithEvent(event, bubbleData: bubbleData)
}
func roomViewController(_ roomViewController: RoomViewController, didRequestLiveLocationPresentationForBubbleData bubbleData: MXKRoomBubbleCellDataStoring) {
guard let roomId = bubbleData.roomId else {
return
}
showLiveLocationViewer(for: roomId)
}
func roomViewController(_ roomViewController: RoomViewController, locationShareActivityViewControllerFor event: MXEvent) -> UIActivityViewController? {
guard let location = event.location else {
return nil
@ -494,11 +598,17 @@ extension RoomCoordinator: RoomViewControllerDelegate {
}
func roomViewControllerDidTapLiveLocationSharingBanner(_ roomViewController: RoomViewController) {
// TODO:
showLiveLocationViewer()
}
func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) {
// TODO:
func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController, beaconInfoEventId: String?) {
guard let roomId = self.roomId else {
return
}
self.stopLiveLocationSharing(forBeaconInfoEventId: beaconInfoEventId, inRoomWithId: roomId)
}
func threadsCoordinator(for roomViewController: RoomViewController, threadId: String?) -> ThreadsCoordinatorBridgePresenter? {

View file

@ -53,7 +53,7 @@ import Foundation
guard let self = self else {
return
}
self.delegate?.roomViewControllerDidStopLiveLocationSharing(self)
self.delegate?.roomViewControllerDidStopLiveLocationSharing(self, beaconInfoEventId: nil)
}
self.topBannersStackView?.addArrangedSubview(bannerView)

View file

@ -70,6 +70,9 @@ extern NSNotificationName const RoomGroupCallTileTappedNotification;
// Remove Jitsi widget container
@property (weak, nonatomic, nullable) IBOutlet UIView *removeJitsiWidgetContainer;
// Error presenter
@property (nonatomic, strong, readonly) MXKErrorAlertPresentation *errorPresenter;
/**
Preview data for a room invitation received by email, or a link to a room.
*/
@ -264,6 +267,10 @@ handleUniversalLinkWithParameters:(UniversalLinkParameters*)parameters;
didRequestLocationPresentationForEvent:(MXEvent *)event
bubbleData:(id<MXKRoomBubbleCellDataStoring>)bubbleData;
/// Ask the coordinator to present the live location sharing viewer.
- (void)roomViewController:(RoomViewController *)roomViewController
didRequestLiveLocationPresentationForBubbleData:(id<MXKRoomBubbleCellDataStoring>)bubbleData;
- (nullable UIActivityViewController *)roomViewController:(RoomViewController *)roomViewController
locationShareActivityViewControllerForEvent:(MXEvent *)event;
@ -296,7 +303,7 @@ didRequestEditForPollWithStartEvent:(MXEvent *)startEvent;
- (void)roomViewControllerDidStopLoading:(RoomViewController *)roomViewController;
/// User tap live location sharing stop action
- (void)roomViewControllerDidStopLiveLocationSharing:(RoomViewController *)roomViewController;
- (void)roomViewControllerDidStopLiveLocationSharing:(RoomViewController *)roomViewController beaconInfoEventId:(nullable NSString*)beaconInfoEventId;
/// User tap live location sharing banner
- (void)roomViewControllerDidTapLiveLocationSharingBanner:(RoomViewController *)roomViewController;

View file

@ -3162,6 +3162,26 @@ static CGSize kThreadListBarButtonItemImageSize;
[self mention:roomMember];
}
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellStopShareButtonPressed])
{
NSString *beaconInfoEventId;
if ([bubbleData isKindOfClass:[RoomBubbleCellData class]])
{
RoomBubbleCellData *roomBubbleCellData = (RoomBubbleCellData*)bubbleData;
beaconInfoEventId = roomBubbleCellData.beaconInfoSummary.id;
}
[self.delegate roomViewControllerDidStopLiveLocationSharing:self beaconInfoEventId:beaconInfoEventId];
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellRetryShareButtonPressed])
{
MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey];
if (selectedEvent)
{
// TODO: - Implement retry live location action
}
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnMessageTextView] || [actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnContentView])
{
// Retrieve the tapped event
@ -3172,6 +3192,10 @@ static CGSize kThreadListBarButtonItemImageSize;
{
[self cancelEventSelection];
}
else if (bubbleData.tag == RoomBubbleCellDataTagLiveLocation)
{
[self.delegate roomViewController:self didRequestLiveLocationPresentationForBubbleData:bubbleData];
}
else if (tappedEvent)
{
if (tappedEvent.eventType == MXEventTypeRoomCreate)

View file

@ -422,6 +422,10 @@ extension ExploreRoomCoordinator: RoomViewControllerDelegate {
// TODO:
}
func roomViewController(_ roomViewController: RoomViewController, didRequestLiveLocationPresentationForBubbleData bubbleData: MXKRoomBubbleCellDataStoring) {
// TODO:
}
func roomViewController(_ roomViewController: RoomViewController, didRequestLocationPresentationFor event: MXEvent, bubbleData: MXKRoomBubbleCellDataStoring) {
// TODO:
}
@ -474,7 +478,7 @@ extension ExploreRoomCoordinator: RoomViewControllerDelegate {
// TODO:
}
func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) {
func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController, beaconInfoEventId: String?) {
// TODO:
}

View file

@ -23,22 +23,40 @@ enum AuthenticationFlow {
}
/// Errors that can be thrown from `AuthenticationService`.
enum AuthenticationError: String, Error {
enum AuthenticationError: String, LocalizedError {
/// A failure to convert a struct into a dictionary.
case dictionaryError
case invalidHomeserver
case loginFlowNotCalled
case missingRegistrationWizard
case missingMXRestClient
var errorDescription: String? {
switch self {
case .invalidHomeserver:
return VectorL10n.authenticationServerSelectionGenericError
default:
return VectorL10n.errorCommonMessage
}
}
}
/// Errors that can be thrown from `RegistrationWizard`
enum RegistrationError: String, Error {
enum RegistrationError: String, LocalizedError {
case registrationDisabled
case createAccountNotCalled
case missingThreePIDData
case missingThreePIDURL
case threePIDValidationFailure
case threePIDClientFailure
var errorDescription: String? {
switch self {
case .registrationDisabled:
return VectorL10n.loginErrorRegistrationIsNotSupported
default:
return VectorL10n.errorCommonMessage
}
}
}
/// Errors that can be thrown from `LoginWizard`

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

@ -48,21 +48,14 @@ class AuthenticationService: NSObject {
// MARK: - Setup
override init() {
if let homeserverURL = URL(string: RiotSettings.shared.homeserverUrlString) {
// Use the same homeserver that was last used.
state = AuthenticationState(flow: .login, homeserverAddress: RiotSettings.shared.homeserverUrlString)
client = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil)
} else if let homeserverURL = URL(string: BuildSettings.serverConfigDefaultHomeserverUrlString) {
// Fall back to the default homeserver if the stored one is invalid.
state = AuthenticationState(flow: .login, homeserverAddress: BuildSettings.serverConfigDefaultHomeserverUrlString)
client = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil)
} else {
guard let homeserverURL = URL(string: BuildSettings.serverConfigDefaultHomeserverUrlString) else {
MXLog.failure("[AuthenticationService]: Failed to create URL from default homeserver URL string.")
fatalError("Invalid default homeserver URL string.")
}
state = AuthenticationState(flow: .login, homeserverAddress: BuildSettings.serverConfigDefaultHomeserverUrlString)
client = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil)
super.init()
}
@ -97,22 +90,25 @@ class AuthenticationService: NSObject {
let loginFlows = try await loginFlow(for: homeserverAddress)
// Valid Homeserver, add it to the history.
// Note: we add what the user has input, as the data can contain a different value.
RiotSettings.shared.homeserverUrlString = homeserverAddress
state.homeserver = .init(address: loginFlows.homeserverAddress,
addressFromUser: homeserverAddress,
preferredLoginMode: loginFlows.loginMode,
loginModeSupportedTypes: loginFlows.supportedLoginTypes)
addressFromUser: homeserverAddress,
preferredLoginMode: loginFlows.loginMode,
loginModeSupportedTypes: loginFlows.supportedLoginTypes)
let loginWizard = LoginWizard()
self.loginWizard = loginWizard
if flow == .registration {
let registrationWizard = RegistrationWizard(client: client)
state.homeserver.registrationFlow = try await registrationWizard.registrationFlow()
self.registrationWizard = registrationWizard
do {
let registrationWizard = RegistrationWizard(client: client)
state.homeserver.registrationFlow = try await registrationWizard.registrationFlow()
self.registrationWizard = registrationWizard
} catch {
guard state.homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else {
throw error
}
// Continue without throwing when registration is disabled but SSO is available.
}
}
state.flow = flow
@ -182,6 +178,7 @@ class AuthenticationService: NSObject {
let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress)
guard var homeserverURL = URL(string: homeserverAddress) else {
MXLog.error("[AuthenticationService] Unable to create a URL from the supplied homeserver address when calling loginFlow.")
throw AuthenticationError.invalidHomeserver
}
@ -207,7 +204,10 @@ class AuthenticationService: NSObject {
/// This method is used to get the flows for a server after a soft-logout.
/// - Parameter session: The MXSession where a soft-logout has occurred.
private func loginFlow(for session: MXSession) async throws -> LoginFlowResult {
guard let client = session.matrixRestClient else { throw AuthenticationError.missingMXRestClient }
guard let client = session.matrixRestClient else {
MXLog.error("[AuthenticationService] loginFlow called on a session that doesn't have a matrixRestClient.")
throw AuthenticationError.missingMXRestClient
}
let state = AuthenticationState(flow: .login, homeserverAddress: client.homeserver)
let loginFlow = try await getLoginFlowResult(client: session.matrixRestClient)

View file

@ -47,6 +47,7 @@ struct RegistrationParameters: Codable {
let jsonData = try JSONEncoder().encode(self)
let object = try JSONSerialization.jsonObject(with: jsonData)
guard let dictionary = object as? [String: Any] else {
MXLog.error("[RegistrationParameters] dictionary: Unexpected type decoded \(type(of: object)). Expected a Dictionary.")
throw AuthenticationError.dictionaryError
}

View file

@ -74,7 +74,16 @@ class RegistrationWizard {
/// See `AuthenticationService.getFallbackUrl`
func registrationFlow() async throws -> RegistrationResult {
let parameters = RegistrationParameters()
return try await performRegistrationRequest(parameters: parameters)
do {
let result = try await performRegistrationRequest(parameters: parameters)
return result
} catch {
// Map M_FORBIDDEN into a registration error.
guard let mxError = MXError(nsError: error), mxError.errcode == kMXErrCodeStringForbidden else { throw error }
MXLog.warning("[RegistrationWizard] Registration is disabled for the selected server.")
throw RegistrationError.registrationDisabled
}
}
/// Can be call to check is the desired username is available for registration on the current homeserver.
@ -94,7 +103,7 @@ class RegistrationWizard {
password: String?,
initialDeviceDisplayName: String?) async throws -> RegistrationResult {
let parameters = RegistrationParameters(username: username, password: password, initialDeviceDisplayName: initialDeviceDisplayName)
let result = try await performRegistrationRequest(parameters: parameters)
let result = try await performRegistrationRequest(parameters: parameters, isCreatingAccount: true)
state.isRegistrationStarted = true
return result
}
@ -104,6 +113,7 @@ class RegistrationWizard {
/// - Parameter response: The response from ReCaptcha
func performReCaptcha(response: String) async throws -> RegistrationResult {
guard let session = state.currentSession else {
MXLog.error("[RegistrationWizard] performReCaptcha: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
@ -114,6 +124,7 @@ class RegistrationWizard {
/// Perform the "m.login.terms" stage.
func acceptTerms() async throws -> RegistrationResult {
guard let session = state.currentSession else {
MXLog.error("[RegistrationWizard] acceptTerms: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
@ -124,6 +135,7 @@ class RegistrationWizard {
/// Perform the "m.login.dummy" stage.
func dummy() async throws -> RegistrationResult {
guard let session = state.currentSession else {
MXLog.error("[RegistrationWizard] dummy: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
@ -143,6 +155,7 @@ class RegistrationWizard {
/// Ask the homeserver to send again the current threePID (email or msisdn).
func sendAgainThreePID() async throws -> RegistrationResult {
guard let threePID = state.currentThreePIDData?.threePID else {
MXLog.error("[RegistrationWizard] sendAgainThreePID: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
return try await sendThreePID(threePID: threePID)
@ -160,6 +173,7 @@ class RegistrationWizard {
func checkIfEmailHasBeenValidated(delay: TimeInterval) async throws -> RegistrationResult {
MXLog.failure("The delay on this method is no longer available. Move this to the object handling the polling.")
guard let parameters = state.currentThreePIDData?.registrationParameters else {
MXLog.error("[RegistrationWizard] checkIfEmailHasBeenValidated: The current 3pid data hasn't been stored in the state.")
throw RegistrationError.missingThreePIDData
}
@ -170,10 +184,12 @@ class RegistrationWizard {
private func validateThreePid(code: String) async throws -> RegistrationResult {
guard let threePIDData = state.currentThreePIDData else {
MXLog.error("[RegistrationWizard] validateThreePid: There is no third party ID data stored in the state.")
throw RegistrationError.missingThreePIDData
}
guard let submitURL = threePIDData.registrationResponse.submitURL else {
MXLog.error("[RegistrationWizard] validateThreePid: The third party ID data doesn't contain a submitURL.")
throw RegistrationError.missingThreePIDURL
}
@ -184,9 +200,11 @@ class RegistrationWizard {
#warning("Seems odd to pass a nil baseURL and then the url as the path, yet this is how MXK3PID works")
guard let httpClient = MXHTTPClient(baseURL: nil, andOnUnrecognizedCertificateBlock: nil) else {
MXLog.error("[RegistrationWizard] validateThreePid: Failed to create an MXHTTPClient.")
throw RegistrationError.threePIDClientFailure
}
guard try await httpClient.validateThreePIDCode(submitURL: submitURL, validationBody: validationBody) else {
MXLog.error("[RegistrationWizard] validateThreePid: Third party ID validation failed.")
throw RegistrationError.threePIDValidationFailure
}
@ -197,6 +215,7 @@ class RegistrationWizard {
private func sendThreePID(threePID: RegisterThreePID) async throws -> RegistrationResult {
guard let session = state.currentSession else {
MXLog.error("[RegistrationWizard] sendThreePID: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
@ -223,7 +242,7 @@ class RegistrationWizard {
return try await performRegistrationRequest(parameters: parameters)
}
private func performRegistrationRequest(parameters: RegistrationParameters) async throws -> RegistrationResult {
private func performRegistrationRequest(parameters: RegistrationParameters, isCreatingAccount: Bool = false) async throws -> RegistrationResult {
do {
let response = try await client.register(parameters: parameters)
let credentials = MXCredentials(loginResponse: response, andDefaultCredentials: client.credentials)
@ -237,7 +256,20 @@ class RegistrationWizard {
else { throw error }
state.currentSession = authenticationSession.session
return .flowResponse(authenticationSession.flowResult)
let flowResult = authenticationSession.flowResult
if isCreatingAccount || isRegistrationStarted {
return try await handleMandatoryDummyStage(flowResult: flowResult)
}
return .flowResponse(flowResult)
}
}
/// Checks for a mandatory dummy stage and handles it automatically when possible.
private func handleMandatoryDummyStage(flowResult: FlowResult) async throws -> RegistrationResult {
// If the dummy stage is mandatory, do the dummy stage now
guard flowResult.missingStages.contains(where: { $0.isDummyAndMandatory }) else { return .flowResponse(flowResult) }
return try await dummy()
}
}

View file

@ -0,0 +1,123 @@
//
// 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
/// Whether or not to show the username and password text fields with the next button
var showRegistrationForm: Bool
/// An array containing the available SSO options for login.
var ssoIdentityProviders: [SSOIdentityProvider]
/// 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 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 {
/// 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
/// The homeserver doesn't support registration.
case registrationDisabled
/// An unknown error occurred.
case unknown
}

View file

@ -0,0 +1,98 @@
//
// 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, showRegistrationForm: Bool = true, ssoIdentityProviders: [SSOIdentityProvider]) {
let bindings = AuthenticationRegistrationBindings()
let viewState = AuthenticationRegistrationViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress),
showRegistrationForm: showRegistrationForm,
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, showRegistrationForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) {
state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress)
state.showRegistrationForm = showRegistrationForm
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 .registrationDisabled:
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
message: VectorL10n.loginErrorRegistrationIsNotSupported)
case .unknown:
state.bindings.alertInfo = AlertInfo(id: type)
}
}
}

View file

@ -0,0 +1,34 @@
//
// 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.
/// - showRegistrationForm: Whether or not to display the username and password text fields.
/// - ssoIdentityProviders: The supported SSO login options.
@MainActor func update(homeserverAddress: String, showRegistrationForm: Bool, ssoIdentityProviders: [SSOIdentityProvider])
/// Display an error to the user.
@MainActor func displayError(_ type: AuthenticationRegistrationErrorType)
}

View file

@ -0,0 +1,246 @@
//
// 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 flow that is available for the chosen server.
let registrationFlow: RegistrationResult?
/// The login mode to allow SSO buttons to be shown when available.
let loginMode: LoginMode
}
enum AuthenticationRegistrationCoordinatorResult {
/// The user would like to select another server.
case selectServer
/// The screen completed with the associated registration result.
case completed(RegistrationResult)
}
@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. May be `nil` when only SSO is supported.
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
self.registrationWizard = parameters.authenticationService.registrationWizard
let homeserver = parameters.authenticationService.state.homeserver
let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
showRegistrationForm: homeserver.registrationFlow != nil,
ssoIdentityProviders: parameters.loginMode.ssoIdentityProviders ?? [])
authenticationRegistrationViewModel = viewModel
let view = AuthenticationRegistrationScreen(viewModel: viewModel.context)
authenticationRegistrationHostingController = VectorHostingController(rootView: view)
authenticationRegistrationHostingController.vc_removeBackTitle()
authenticationRegistrationHostingController.enableNavigationBarScrollEdgeAppearance = true
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) {
guard let registrationWizard = registrationWizard else {
MXLog.failure("[AuthenticationRegistrationCoordinator] The registration wizard was requested before getting the login flow.")
return
}
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) {
guard let registrationWizard = registrationWizard else {
MXLog.failure("[AuthenticationRegistrationCoordinator] createAccount: The registration wizard is nil.")
return
}
// reAuthHelper.data = state.password
let deviceDisplayName = UIDevice.current.isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice
startLoading()
currentTask = Task { [weak self] in
do {
let result = try await registrationWizard.createAccount(username: username, password: password, initialDeviceDisplayName: deviceDisplayName)
guard !Task.isCancelled else { return }
completion?(.completed(result))
self?.stopLoading()
} catch {
self?.stopLoading()
self?.handleError(error)
}
}
}
/// Processes an error to either update the flow or display it to the user.
@MainActor private func handleError(_ error: Error) {
if let mxError = MXError(nsError: error as NSError) {
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:
#warning("Reset the flow")
case .missingMXRestClient:
#warning("Forget the soft logout session")
}
return
}
if let registrationError = error as? RegistrationError {
switch registrationError {
case .registrationDisabled:
authenticationRegistrationViewModel.displayError(.registrationDisabled)
case .createAccountNotCalled, .missingThreePIDData, .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 result == .updated {
let homeserver = authenticationService.state.homeserver
authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
showRegistrationForm: homeserver.registrationFlow != nil,
ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? [])
self.registrationWizard = authenticationService.registrationWizard
}
navigationRouter.dismissModule(animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
}

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
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:
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://company.com",
showRegistrationForm: false,
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,144 @@
//
// 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"
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,218 @@
//
// 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, showRegistrationForm: true, ssoIdentityProviders: ssoProviders)
// Then the form should show the server description along with the username and password fields and the SSO buttons.
XCTAssertEqual(context.viewState.homeserverAddress, "matrix.org", "The homeserver address should have the https scheme stripped away.")
XCTAssertEqual(context.viewState.serverDescription, VectorL10n.authenticationRegistrationMatrixDescription, "A description should be shown for matrix.org.")
XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.")
XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.")
}
func testBasicServer() {
// Given a basic server example.com that only supports password registration.
let address = "https://example.com"
// When updating the view model with the server.
viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: [])
// Then the form should only show the username and password section.
XCTAssertEqual(context.viewState.homeserverAddress, "example.com", "The homeserver address should have the https scheme stripped away.")
XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.")
XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.")
XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.")
}
func testUnsecureServer() {
// Given a server that uses http for communication.
let address = "http://testserver.local"
// When updating the view model with the server.
viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: [])
// Then the form should only show the username and password section.
XCTAssertEqual(context.viewState.homeserverAddress, address, "The homeserver address should show the http scheme.")
XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.")
}
func testSSOOnlyServer() {
// Given matrix.org with some SSO providers.
let address = "https://example.com"
let ssoProviders = [
SSOIdentityProvider(id: "apple", name: "Apple", brand: "Apple", iconURL: nil),
SSOIdentityProvider(id: "google", name: "Google", brand: "Google", iconURL: nil),
SSOIdentityProvider(id: "github", name: "Github", brand: "Github", iconURL: nil)
]
// When updating the view model with the server.
viewModel.update(homeserverAddress: address, showRegistrationForm: false, ssoIdentityProviders: ssoProviders)
// Then the form should show the server description along with the username and password fields and the SSO buttons.
XCTAssertEqual(context.viewState.homeserverAddress, "example.com", "The homeserver address should have the https scheme stripped away.")
XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.")
XCTAssertFalse(context.viewState.showRegistrationForm, "The username and password section should not be shown.")
XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.")
}
func testUsernameError() async {
// Given a form with a valid username.
context.username = "bob"
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,82 @@
//
// 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 confirm(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 title shown on the confirm button.
var buttonTitle: String {
hasModalPresentation ? VectorL10n.confirm : VectorL10n.next
}
/// The text field is showing an error.
var isShowingFooterError: Bool {
footerErrorMessage != nil
}
/// Whether it is possible to continue when tapping the confirmation button.
var hasValidationError: Bool {
bindings.homeserverAddress.isEmpty || isShowingFooterError
}
}
struct AuthenticationServerSelectionBindings {
/// 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 confirm
/// 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.displayable(homeserverAddress))
super.init(initialViewState: AuthenticationServerSelectionViewState(bindings: bindings,
hasModalPresentation: hasModalPresentation))
}
// MARK: - Public
override func process(viewAction: AuthenticationServerSelectionViewAction) {
Task {
await MainActor.run {
switch viewAction {
case .confirm:
completion?(.confirm(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,139 @@
//
// 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
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 homeserver = parameters.authenticationService.state.homeserver
let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
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 .confirm(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.sanitized(homeserverAddress)
Task {
do {
#warning("The screen should be configuration for .login too.")
try await authenticationService.startFlow(.registration, for: homeserverAddress)
stopLoading()
completion?(.updated)
} catch {
stopLoading()
if let error = error as? RegistrationError {
authenticationServerSelectionViewModel.displayError(.footerMessage(error.localizedDescription))
} else {
// 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,91 @@
//
// 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 confirmButton = app.buttons["confirmButton"]
XCTAssertEqual(confirmButton.label, VectorL10n.confirm, "The confirm button should say Confirm when in modal presentation.")
XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.")
XCTAssertTrue(confirmButton.isEnabled, "The confirm 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 confirmButton = app.buttons["confirmButton"]
XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.")
XCTAssertFalse(confirmButton.isEnabled, "The confirm 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 confirmButton = app.buttons["confirmButton"]
XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.")
XCTAssertFalse(confirmButton.isEnabled, "The confirm 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.")
let confirmButton = app.buttons["confirmButton"]
XCTAssertEqual(confirmButton.label, VectorL10n.next, "The confirm button should say Next 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())
.toolbar { toolbar }
.alert(item: $viewModel.alertInfo) { $0.alert }
.accentColor(theme.colors.accent)
}
/// 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 confirm 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: .confirm) } label: {
Text(viewModel.viewState.buttonTitle)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(viewModel.viewState.hasValidationError)
.accessibilityIdentifier("confirmButton")
}
}
/// 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: .cancellationAction) {
if viewModel.viewState.hasModalPresentation {
Button { viewModel.send(viewAction: .dismiss) } label: {
Text(VectorL10n.cancel)
}
.accessibilityLabel(VectorL10n.cancel)
.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))

View file

@ -18,6 +18,8 @@ import SwiftUI
struct LiveLocationSharingViewerCoordinatorParameters {
let session: MXSession
let roomId: String
let navigationRouter: NavigationRouterType?
}
final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable {
@ -27,6 +29,7 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable {
// MARK: Private
private let parameters: LiveLocationSharingViewerCoordinatorParameters
private let navigationRouter: NavigationRouterType
private let liveLocationSharingViewerHostingController: UIViewController
private var liveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelProtocol
@ -36,6 +39,7 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable {
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
// MARK: - Setup
@ -44,13 +48,15 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable {
init(parameters: LiveLocationSharingViewerCoordinatorParameters) {
self.parameters = parameters
let service = LiveLocationSharingViewerService(session: parameters.session)
let service = LiveLocationSharingViewerService(session: parameters.session, roomId: parameters.roomId)
let viewModel = LiveLocationSharingViewerViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL, service: service)
let view = LiveLocationSharingViewer(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
liveLocationSharingViewerViewModel = viewModel
liveLocationSharingViewerHostingController = VectorHostingController(rootView: view)
navigationRouter = parameters.navigationRouter ?? NavigationRouter()
}
// MARK: - Public
@ -64,14 +70,21 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable {
self.completion?()
case .share(let coordinate):
self.presentLocationActivityController(with: coordinate)
case .stopLocationSharing:
self.stopLocationSharing()
}
}
let viewController: UIViewController = self.liveLocationSharingViewerHostingController
if navigationRouter.modules.count > 1 {
navigationRouter.push(viewController, animated: true, popCompletion: nil)
} else {
navigationRouter.setRootModule(viewController)
}
}
func toPresentable() -> UIViewController {
return self.liveLocationSharingViewerHostingController
return navigationRouter.toPresentable()
.vc_setModalFullScreen(true) // Set fullscreen as DSBottomSheet is not working with modal pan gesture recognizer
}
func presentLocationActivityController(with coordinate: CLLocationCoordinate2D) {
@ -80,8 +93,4 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable {
self.liveLocationSharingViewerHostingController.present(shareActivityController, animated: true)
}
func stopLocationSharing() {
// TODO: Handle stop location sharing
}
}

View file

@ -25,7 +25,6 @@ import CoreLocation
enum LiveLocationSharingViewerViewModelResult {
case done
case share(_ coordinate: CLLocationCoordinate2D)
case stopLocationSharing
}
// MARK: View

View file

@ -29,9 +29,11 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType
// MARK: Private
private let liveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol
private var liveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol
private var mapViewErrorAlertInfoBuilder: MapViewErrorAlertInfoBuilder
private var screenUpdateTimer: Timer?
// MARK: Public
@ -53,7 +55,8 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType
self.processError(error)
}.store(in: &cancellables)
self.update(with: service.usersLiveLocation)
self.setupLocationSharingService()
self.setupScreenUpdateTimer()
}
// MARK: - Public
@ -63,7 +66,7 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType
case .done:
completion?(.done)
case .stopSharing:
completion?(.stopLocationSharing)
stopUserLocationSharing()
case .tapListItem(let userId):
self.highlighAnnotation(with: userId)
case .share(let userLocationAnnotation):
@ -73,6 +76,34 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType
// MARK: - Private
private func setupLocationSharingService() {
self.updateUsersLiveLocation(highlightFirstLocation: true)
liveLocationSharingViewerService.didUpdateUsersLiveLocation = { [weak self] liveLocations in
self?.update(with: liveLocations, highlightFirstLocation: false)
}
self.liveLocationSharingViewerService.startListeningLiveLocationUpdates()
}
private func updateUsersLiveLocation(highlightFirstLocation: Bool) {
self.update(with: liveLocationSharingViewerService.usersLiveLocation, highlightFirstLocation: highlightFirstLocation)
}
private func setupScreenUpdateTimer() {
self.screenUpdateTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] timer in
self?.updateUsersLiveLocation(highlightFirstLocation: false)
}
}
private func showNoUserLocationsAlert() {
let alertInfo: AlertInfo<LocationSharingAlertType> = AlertInfo(id: .userLocatingError, title: VectorL10n.locationSharingLiveNoUserLocationsErrorTitle, primaryButton:(VectorL10n.ok, { [weak self] in
self?.completion?(.done)
}))
state.bindings.alertInfo = alertInfo
}
private func processError(_ error: LocationSharingViewError) {
guard state.bindings.alertInfo == nil else {
return
@ -154,17 +185,27 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType
return LiveLocationListItemViewData(userId: userLiveLocation.userId, isCurrentUser: isCurrentUser, avatarData: userLiveLocation.avatarData, displayName: userLiveLocation.displayName, expirationDate: expirationDate, lastUpdate: userLiveLocation.lastUpdate)
}
private func update(with usersLiveLocation: [UserLiveLocation]) {
private func update(with usersLiveLocation: [UserLiveLocation], highlightFirstLocation: Bool) {
let annotations: [UserLocationAnnotation] = self.userLocationAnnotations(from: usersLiveLocation)
let highlightedAnnotation = self.getHighlightedAnnotation(from: annotations)
var highlightedAnnotation: UserLocationAnnotation?
if highlightFirstLocation {
highlightedAnnotation = self.getHighlightedAnnotation(from: annotations)
}
let listViewItems = self.listItemsViewData(from: usersLiveLocation)
self.state.annotations = annotations
self.state.highlightedAnnotation = highlightedAnnotation
self.state.listItemsViewData = listViewItems
if usersLiveLocation.isEmpty {
// Advertize user that there is no locations
// Avoid to let the screen empty
self.showNoUserLocationsAlert()
}
}
private func highlighAnnotation(with userId: String) {
@ -178,4 +219,23 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType
self.state.highlightedAnnotation = foundUserAnnotation
}
private func stopUserLocationSharing() {
self.state.showLoadingIndicator = true
self.liveLocationSharingViewerService.stopUserLiveLocationSharing { result in
self.state.showLoadingIndicator = false
switch result {
case .success:
break
case.failure:
self.state.bindings.alertInfo = AlertInfo(id: .stopLocationSharingError,
title: VectorL10n.error,
message: VectorL10n.locationSharingLiveStopSharingError,
primaryButton: (VectorL10n.ok, nil))
}
}
}
}

View file

@ -21,7 +21,18 @@ import CoreLocation
@available(iOS 14.0, *)
protocol LiveLocationSharingViewerServiceProtocol {
/// All shared users live location
var usersLiveLocation: [UserLiveLocation] { get }
/// Called when users live location are updated (new location, location stopped, ).
var didUpdateUsersLiveLocation: (([UserLiveLocation]) -> Void)? { get set }
func isCurrentUserId(_ userId: String) -> Bool
func startListeningLiveLocationUpdates()
func stopListeningLiveLocationUpdates()
/// Stop current user location sharing
func stopUserLiveLocationSharing(completion: @escaping (Result<Void, Error>) -> Void)
}

View file

@ -16,6 +16,7 @@
import Foundation
import CoreLocation
import MatrixSDK
@available(iOS 14.0, *)
class LiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol {
@ -23,6 +24,8 @@ class LiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol
// MARK: - Properties
private(set) var usersLiveLocation: [UserLiveLocation] = []
private let roomId: String
private var beaconInfoSummaryListener: Any?
// MARK: Private
@ -30,13 +33,83 @@ class LiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol
// MARK: Public
func isCurrentUserId(_ userId: String) -> Bool {
return false
}
var didUpdateUsersLiveLocation: (([UserLiveLocation]) -> Void)?
// MARK: - Setup
init(session: MXSession) {
init(session: MXSession, roomId: String) {
self.session = session
self.roomId = roomId
self.updateUsersLiveLocation(notifyUpdate: false)
}
// MARK: - Public
func isCurrentUserId(_ userId: String) -> Bool {
return self.session.myUserId == userId
}
func startListeningLiveLocationUpdates() {
self.beaconInfoSummaryListener = self.session.aggregations.beaconAggregations.listenToBeaconInfoSummaryUpdateInRoom(withId: self.roomId) { [weak self] _ in
self?.updateUsersLiveLocation(notifyUpdate: true)
}
}
func stopListeningLiveLocationUpdates() {
if let listener = beaconInfoSummaryListener {
self.session.aggregations.removeListener(listener)
self.beaconInfoSummaryListener = nil
}
}
func stopUserLiveLocationSharing(completion: @escaping (Result<Void, Error>) -> Void) {
self.session.locationService.stopUserLocationSharing(inRoomWithId: roomId) { response in
switch response {
case .success:
completion(.success(Void()))
case .failure(let error):
completion(.failure(error))
}
}
}
// MARK: - Private
private func updateUsersLiveLocation(notifyUpdate: Bool) {
let beaconInfoSummaries = self.session.locationService.getDisplayableBeaconInfoSummaries(inRoomWithId: roomId)
self.usersLiveLocation = Self.usersLiveLocation(fromBeaconInfoSummaries: beaconInfoSummaries, session: session)
if notifyUpdate {
self.didUpdateUsersLiveLocation?(self.usersLiveLocation)
}
}
class private func usersLiveLocation(fromBeaconInfoSummaries beaconInfoSummaries: [MXBeaconInfoSummaryProtocol], session: MXSession) -> [UserLiveLocation] {
return beaconInfoSummaries.compactMap { beaconInfoSummary in
let beaconInfo = beaconInfoSummary.beaconInfo
guard let lastBeacon = beaconInfoSummary.lastBeacon else {
return nil
}
let avatarData = session.avatarInput(for: beaconInfoSummary.userId)
let timestamp = TimeInterval(beaconInfo.timestamp/1000)
let timeout = TimeInterval(beaconInfo.timeout/1000)
let lastUpdate = TimeInterval(lastBeacon.timestamp/1000)
let coordinate = CLLocationCoordinate2D(latitude: lastBeacon.location.latitude, longitude: lastBeacon.location.longitude)
return UserLiveLocation(avatarData: avatarData,
timestamp: timestamp,
timeout: timeout,
lastUpdate: lastUpdate,
coordinate: coordinate)
}
}
}

View file

@ -21,11 +21,13 @@ import CoreLocation
@available(iOS 14.0, *)
class MockLiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol {
// MARK: Properties
private(set) var usersLiveLocation: [UserLiveLocation] = []
func isCurrentUserId(_ userId: String) -> Bool {
return "@alice:matrix.org" == userId
}
var didUpdateUsersLiveLocation: (([UserLiveLocation]) -> Void)?
// MARK: Setup
init(generateRandomUsers: Bool = false) {
@ -46,6 +48,26 @@ class MockLiveLocationSharingViewerService: LiveLocationSharingViewerServiceProt
self.usersLiveLocation = usersLiveLocation
}
// MARK: Public
func isCurrentUserId(_ userId: String) -> Bool {
return "@alice:matrix.org" == userId
}
func startListeningLiveLocationUpdates() {
}
func stopListeningLiveLocationUpdates() {
}
func stopUserLiveLocationSharing(completion: @escaping (Result<Void, Error>) -> Void) {
}
// MARK: Private
private func createFirstUserLiveLocation() -> UserLiveLocation {
let userAvatarData = AvatarInput(mxContentUri: nil, matrixItemId: "@alice:matrix.org", displayName: "Alice")
let userCoordinate = CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096)

View file

@ -102,7 +102,7 @@ struct LiveLocationListItem: View {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
formatter.allowedUnits = [.hour, .minute]
formatter.allowedUnits = [.hour, .minute, .second]
let date = Date(timeIntervalSince1970: timestamp)

View file

@ -56,6 +56,13 @@ struct LiveLocationSharingViewer: View {
}
}
.navigationTitle(VectorL10n.locationSharingLiveViewerTitle)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(VectorL10n.cancel) {
viewModel.send(viewAction: .done)
}
}
}
.accentColor(theme.colors.accent)
.bottomSheet(sheet, if: isBottomSheetVisible)
.alert(item: $viewModel.alertInfo) { info in

View file

@ -103,4 +103,5 @@ enum LocationSharingAlertType {
case userLocatingError
case authorizationError
case locationSharingError
case stopLocationSharingError
}

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.

1
changelog.d/6081.wip Normal file
View file

@ -0,0 +1 @@
Location sharing: Integrate live location viewer screen with room screen.