Merge pull request #6243 from vector-im/release/1.8.18/release

Release 1.8.18
This commit is contained in:
ismailgulek
2022-06-03 14:03:46 +03:00
committed by GitHub
68 changed files with 2053 additions and 497 deletions

View File

@@ -15,7 +15,7 @@ env:
jobs:
build:
name: Build
runs-on: macos-11
runs-on: macos-12
# Concurrency group not needed as this workflow only runs on develop which we always want to test.

View File

@@ -16,7 +16,7 @@ env:
jobs:
tests:
name: Tests
runs-on: macos-11
runs-on: macos-12
concurrency:
# When running on develop, use the sha to allow all runs of this workflow to run concurrently.

View File

@@ -14,7 +14,7 @@ env:
jobs:
tests:
name: UI Tests
runs-on: macos-11
runs-on: macos-12
concurrency:
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.

View File

@@ -14,7 +14,7 @@ env:
jobs:
check-secret:
runs-on: macos-11
runs-on: macos-12
outputs:
out-key: ${{ steps.out-key.outputs.defined }}
steps:
@@ -29,7 +29,7 @@ jobs:
needs: [check-secret]
if: needs.check-secret.outputs.out-key == 'true'
name: Release
runs-on: macos-11
runs-on: macos-12
concurrency:
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.

View File

@@ -9,15 +9,15 @@ jobs:
name: Add Z-Labs label for features behind labs flags
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'A-Maths') ||
contains(github.event.issue.labels.*.name, 'A-Message-Pinning') ||
contains(github.event.issue.labels.*.name, 'A-Polls') ||
contains(github.event.issue.labels.*.name, 'A-Location-Sharing') ||
contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') ||
contains(github.event.issue.labels.*.name, 'Z-IA') ||
contains(github.event.issue.labels.*.name, 'A-Themes-Custom') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') ||
contains(github.event.issue.labels.*.name, 'A-Tags')
contains(github.event.issue.labels.*.name, 'A-Maths') ||
contains(github.event.issue.labels.*.name, 'A-Message-Pinning') ||
contains(github.event.issue.labels.*.name, 'A-Polls') ||
contains(github.event.issue.labels.*.name, 'A-Location-Sharing') ||
contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') ||
contains(github.event.issue.labels.*.name, 'Z-IA') ||
contains(github.event.issue.labels.*.name, 'A-Themes-Custom') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') ||
contains(github.event.issue.labels.*.name, 'A-Tags')
steps:
- uses: actions/github-script@v5
with:
@@ -44,14 +44,14 @@ jobs:
name: P1 X-Needs-Design to Design project board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'X-Needs-Design') &&
(contains(github.event.issue.labels.*.name, 'S-Critical') &&
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
contains(github.event.issue.labels.*.name, 'S-Major') &&
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'A11y') &&
contains(github.event.issue.labels.*.name, 'O-Frequent'))
contains(github.event.issue.labels.*.name, 'X-Needs-Design') &&
(contains(github.event.issue.labels.*.name, 'S-Critical') &&
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
contains(github.event.issue.labels.*.name, 'S-Major') &&
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'A11y') &&
contains(github.event.issue.labels.*.name, 'O-Frequent'))
steps:
- uses: octokit/graphql-action@v2.x
id: add_to_project
@@ -75,7 +75,7 @@ jobs:
name: X-Needs-Product to Design project board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'X-Needs-Product')
contains(github.event.issue.labels.*.name, 'X-Needs-Product')
steps:
- uses: octokit/graphql-action@v2.x
id: add_to_project
@@ -99,10 +99,7 @@ jobs:
name: Spaces issues to Delight project board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'A-Spaces') ||
contains(github.event.issue.labels.*.name, 'A-Space-Settings') ||
contains(github.event.issue.labels.*.name, 'A-Subspaces') ||
contains(github.event.issue.labels.*.name, 'Z-IA')
contains(github.event.issue.labels.*.name, 'Team: Delight')
steps:
- uses: octokit/graphql-action@v2.x
with:
@@ -125,7 +122,7 @@ jobs:
name: A-Voice Messages to voice message board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'A-Voice Messages')
contains(github.event.issue.labels.*.name, 'A-Voice Messages')
steps:
- uses: octokit/graphql-action@v2.x
with:
@@ -148,7 +145,7 @@ jobs:
name: A-Threads to Thread board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'A-Threads')
contains(github.event.issue.labels.*.name, 'A-Threads')
steps:
- uses: octokit/graphql-action@v2.x
with:
@@ -171,7 +168,7 @@ jobs:
name: A-Message-Bubbles to Message bubble board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'A-Message-Bubbles')
contains(github.event.issue.labels.*.name, 'A-Message-Bubbles')
steps:
- uses: octokit/graphql-action@v2.x
with:
@@ -194,7 +191,7 @@ jobs:
name: Z-FTUE to FTUE board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'Z-FTUE')
contains(github.event.issue.labels.*.name, 'Z-FTUE')
steps:
- uses: octokit/graphql-action@v2.x
with:
@@ -212,12 +209,12 @@ jobs:
env:
PROJECT_ID: "PN_kwDOAM0swc4AAqVx"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
move_WTF_issues:
name: Z-WTF to WTF board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'Z-WTF')
contains(github.event.issue.labels.*.name, 'Z-WTF')
steps:
- uses: octokit/graphql-action@v2.x
with:

View File

@@ -8,22 +8,19 @@ jobs:
p1_issues_to_team_workboard:
runs-on: ubuntu-latest
if: >
(!contains(github.event.issue.labels.*.name, 'A-E2EE') &&
!contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') &&
!contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') &&
!contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') &&
!contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification') &&
!contains(github.event.issue.labels.*.name, 'A-Spaces') &&
!contains(github.event.issue.labels.*.name, 'A-Spaces-Settings') &&
!contains(github.event.issue.labels.*.name, 'A-Subspaces')) &&
(contains(github.event.issue.labels.*.name, 'T-Defect') &&
contains(github.event.issue.labels.*.name, 'S-Critical') &&
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
contains(github.event.issue.labels.*.name, 'S-Major') &&
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'A11y') &&
contains(github.event.issue.labels.*.name, 'O-Frequent'))
(!contains(github.event.issue.labels.*.name, 'A-E2EE') &&
!contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') &&
!contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') &&
!contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') &&
!contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification') &&
(contains(github.event.issue.labels.*.name, 'T-Defect') &&
contains(github.event.issue.labels.*.name, 'S-Critical') &&
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
contains(github.event.issue.labels.*.name, 'S-Major') &&
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'A11y') &&
contains(github.event.issue.labels.*.name, 'O-Frequent'))
steps:
- uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488
with:
@@ -34,20 +31,20 @@ jobs:
P1_issues_to_crypto_team_workboard:
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'Z-UISI') ||
(contains(github.event.issue.labels.*.name, 'A-E2EE') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification')) &&
(contains(github.event.issue.labels.*.name, 'T-Defect') &&
contains(github.event.issue.labels.*.name, 'S-Critical') &&
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
contains(github.event.issue.labels.*.name, 'S-Major') &&
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'A11y') &&
contains(github.event.issue.labels.*.name, 'O-Frequent'))
contains(github.event.issue.labels.*.name, 'Z-UISI') ||
(contains(github.event.issue.labels.*.name, 'A-E2EE') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') ||
contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification')) &&
(contains(github.event.issue.labels.*.name, 'T-Defect') &&
contains(github.event.issue.labels.*.name, 'S-Critical') &&
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'O-Occasional')) ||
contains(github.event.issue.labels.*.name, 'S-Major') &&
contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'A11y') &&
contains(github.event.issue.labels.*.name, 'O-Frequent'))
steps:
- uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488
with:

View File

@@ -1,3 +1,26 @@
## Changes in 1.8.18 (2022-06-03)
🙌 Improvements
- Upgrade MatrixSDK version ([v0.23.8](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.23.8)).
- Show user indicators when paginating a room ([#5746](https://github.com/vector-im/element-ios/issues/5746))
- Authentication: Display fallback screens on registration & login according to the HS needs. ([#6176](https://github.com/vector-im/element-ios/issues/6176))
- WellKnown: support outbound keys presharing strategy ([#6214](https://github.com/vector-im/element-ios/issues/6214))
🐛 Bugfixes
- Location sharing: Improve automatic detection of pin drop state ([#6202](https://github.com/vector-im/element-ios/issues/6202))
🧱 Build
- Ensure that warnings from CocoaPods dependencies do not show up in Xcode ([#6196](https://github.com/vector-im/element-ios/pull/6196))
- CI: Use macOS 12 and Xcode 13.4 ([#6204](https://github.com/vector-im/element-ios/pull/6204))
🚧 In development 🚧
- Authentication: Add the login screen to the new flow and support SSO on both login and registration flows. ([#5654](https://github.com/vector-im/element-ios/issues/5654))
## Changes in 1.8.17 (2022-05-31)
🙌 Improvements

View File

@@ -15,5 +15,5 @@
//
// Version
MARKETING_VERSION = 1.8.17
CURRENT_PROJECT_VERSION = 1.8.17
MARKETING_VERSION = 1.8.18
CURRENT_PROJECT_VERSION = 1.8.18

16
Podfile
View File

@@ -3,6 +3,9 @@ source 'https://cdn.cocoapods.org/'
# Uncomment this line to define a global platform for your project
platform :ios, '14.0'
# By default, ignore all warnings from any pod
inhibit_all_warnings!
# Use frameworks to allow usage of pods written in Swift
use_frameworks!
@@ -13,7 +16,7 @@ use_frameworks!
# - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI
#
# Warning: our internal tooling depends on the name of this variable name, so be sure not to change it
$matrixSDKVersion = '= 0.23.7'
$matrixSDKVersion = '= 0.23.8'
# $matrixSDKVersion = :local
# $matrixSDKVersion = { :branch => 'develop'}
# $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } }
@@ -42,8 +45,8 @@ end
# Method to import the MatrixSDK
def import_MatrixSDK
pod 'MatrixSDK', $matrixSDKVersionSpec
pod 'MatrixSDK/JingleCallStack', $matrixSDKVersionSpec
pod 'MatrixSDK', $matrixSDKVersionSpec, :inhibit_warnings => false
pod 'MatrixSDK/JingleCallStack', $matrixSDKVersionSpec, :inhibit_warnings => false
end
########################################
@@ -69,12 +72,11 @@ abstract_target 'RiotPods' do
# PostHog for analytics
pod 'PostHog', '~> 1.4.4'
pod 'AnalyticsEvents', :git => 'https://github.com/matrix-org/matrix-analytics-events.git', :branch => 'release/swift'
pod 'AnalyticsEvents', :git => 'https://github.com/matrix-org/matrix-analytics-events.git', :branch => 'release/swift', :inhibit_warnings => false
# pod 'AnalyticsEvents', :path => '../matrix-analytics-events/AnalyticsEvents.podspec'
# Remove warnings from "bad" pods
pod 'OLMKit', :inhibit_warnings => true
pod 'zxcvbn-ios', :inhibit_warnings => true
pod 'OLMKit'
pod 'zxcvbn-ios'
# Tools
pod 'SwiftGen', '~> 6.3'

View File

@@ -229,6 +229,6 @@ SPEC CHECKSUMS:
zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
PODFILE CHECKSUM: 525efbc379e42474c461991425266c166593d69f
PODFILE CHECKSUM: 37d7f07953015898ac6f2e628e9b20c2bdd55753
COCOAPODS: 1.11.2
COCOAPODS: 1.11.3

View File

@@ -23,13 +23,17 @@
// 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_login_title" = "Welcome back!";
"authentication_login_username" = "Username or Email";
"authentication_login_forgot_password" = "Forgot password";
"authentication_server_info_title" = "Choose your server to store your data";
"authentication_server_info_matrix_description" = "Join millions for free on the largest public server";
"authentication_server_selection_title" = "Choose your server";
"authentication_server_selection_message" = "What is the address of your server? A server is like a home for all your data.";
"authentication_server_selection_server_url" = "Server URL";

View File

@@ -16,16 +16,19 @@
import Foundation
struct HomeserverAddress {
/// Ensures the address contains a scheme, otherwise makes it `https`.
static func sanitized(_ address: String) -> String {
!address.contains("://") ? "https://\(address.lowercased())" : address.lowercased()
}
/// Strips the `https://` away from the address (but leaves `http://`) for display in labels.
///
/// `http://` is left in the string to make it clear when a chosen server doesn't use SSL.
static func displayable(_ address: String) -> String {
address.replacingOccurrences(of: "https://", with: "")
extension MXKKeyPreSharingStrategy {
init?(key: String?) {
guard let key = key else {
return nil
}
switch key {
case "on_typing":
self = .whenTyping
case "on_room_opening":
self = .whenEnteringRoom
default:
self = .none
}
}
}

View File

@@ -34,4 +34,8 @@ import UIKit
return userInterfaceIdiom == .phone
}
var initialDisplayName: String {
isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice
}
}

View File

@@ -14,30 +14,30 @@ public extension VectorL10n {
static var authenticationCancelFlowConfirmationMessage: String {
return VectorL10n.tr("Untranslated", "authentication_cancel_flow_confirmation_message")
}
/// Forgot password
static var authenticationLoginForgotPassword: String {
return VectorL10n.tr("Untranslated", "authentication_login_forgot_password")
}
/// Welcome back!
static var authenticationLoginTitle: String {
return VectorL10n.tr("Untranslated", "authentication_login_title")
}
/// Username or Email
static var authenticationLoginUsername: String {
return VectorL10n.tr("Untranslated", "authentication_login_username")
}
/// This server would like to make sure you are not a robot
static var authenticationRecaptchaMessage: String {
return VectorL10n.tr("Untranslated", "authentication_recaptcha_message")
}
/// Join millions for free on the largest public server
static var authenticationRegistrationMatrixDescription: String {
return VectorL10n.tr("Untranslated", "authentication_registration_matrix_description")
}
/// 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")
@@ -50,6 +50,14 @@ public extension VectorL10n {
static var authenticationRegistrationUsernameFooter: String {
return VectorL10n.tr("Untranslated", "authentication_registration_username_footer")
}
/// Join millions for free on the largest public server
static var authenticationServerInfoMatrixDescription: String {
return VectorL10n.tr("Untranslated", "authentication_server_info_matrix_description")
}
/// Choose your server to store your data
static var authenticationServerInfoTitle: String {
return VectorL10n.tr("Untranslated", "authentication_server_info_title")
}
/// Cannot find a server at this URL, please check it is correct.
static var authenticationServerSelectionGenericError: String {
return VectorL10n.tr("Untranslated", "authentication_server_selection_generic_error")

View File

@@ -41,6 +41,8 @@ final class HomeserverConfigurationBuilder: NSObject {
let isE2EEByDefaultEnabled = vectorWellKnownEncryptionConfiguration?.isE2EEByDefaultEnabled ?? true
// Disable mandatory secure backup when there is no value
let isSecureBackupRequired = vectorWellKnownEncryptionConfiguration?.isSecureBackupRequired ?? false
// Default to `MXKKeyPreSharingWhenTyping` when there is no value
let outboundKeysPreSharingMode = vectorWellKnownEncryptionConfiguration?.outboundKeysPreSharingMode ?? .whenTyping
// Defaults to all secure backup methods available when there is no value
let secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]
if let backupSetupMethods = vectorWellKnownEncryptionConfiguration?.secureBackupSetupMethods {
@@ -51,7 +53,8 @@ final class HomeserverConfigurationBuilder: NSObject {
let encryptionConfiguration = HomeserverEncryptionConfiguration(isE2EEByDefaultEnabled: isE2EEByDefaultEnabled,
isSecureBackupRequired: isSecureBackupRequired,
secureBackupSetupMethods: secureBackupSetupMethods)
secureBackupSetupMethods: secureBackupSetupMethods,
outboundKeysPreSharingMode: outboundKeysPreSharingMode)
// Jitsi configuration
let jitsiPreferredDomain: String?

View File

@@ -22,12 +22,15 @@ final class HomeserverEncryptionConfiguration: NSObject {
let isE2EEByDefaultEnabled: Bool
let isSecureBackupRequired: Bool
let secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]
let outboundKeysPreSharingMode: MXKKeyPreSharingStrategy
init(isE2EEByDefaultEnabled: Bool,
isSecureBackupRequired: Bool,
secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]) {
secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod],
outboundKeysPreSharingMode: MXKKeyPreSharingStrategy) {
self.isE2EEByDefaultEnabled = isE2EEByDefaultEnabled
self.isSecureBackupRequired = isSecureBackupRequired
self.outboundKeysPreSharingMode = outboundKeysPreSharingMode
self.secureBackupSetupMethods = secureBackupSetupMethods
super.init()

View File

@@ -48,6 +48,8 @@ struct VectorWellKnownEncryptionConfiguration {
let isSecureBackupRequired: Bool?
/// Methods to use to setup secure backup (SSSS).
let secureBackupSetupMethods: [VectorWellKnownBackupSetupMethod]?
/// Outbound keys pre sharing strategy.
let outboundKeysPreSharingMode: MXKKeyPreSharingStrategy?
}
extension VectorWellKnownEncryptionConfiguration: Decodable {
@@ -56,6 +58,7 @@ extension VectorWellKnownEncryptionConfiguration: Decodable {
case isE2EEByDefaultEnabled = "default"
case isSecureBackupRequired = "secure_backup_required"
case secureBackupSetupMethods = "secure_backup_setup_methods"
case outboundKeysPreSharingMode = "outbound_keys_pre_sharing_mode"
}
init(from decoder: Decoder) throws {
@@ -64,6 +67,8 @@ extension VectorWellKnownEncryptionConfiguration: Decodable {
isSecureBackupRequired = try? container.decode(Bool.self, forKey: .isSecureBackupRequired)
let secureBackupSetupMethodsKeys = try? container.decode([String].self, forKey: .secureBackupSetupMethods)
secureBackupSetupMethods = secureBackupSetupMethodsKeys?.compactMap { VectorWellKnownBackupSetupMethod(key: $0) }
let outboundKeysPreSharingModeKey = try? container.decode(String.self, forKey: .outboundKeysPreSharingMode)
outboundKeysPreSharingMode = MXKKeyPreSharingStrategy(key: outboundKeysPreSharingModeKey)
}
}

View File

@@ -189,8 +189,8 @@ final class AppCoordinator: NSObject, AppCoordinatorType {
let canOpenLink: Bool
switch deepLinkOption {
case .connect(let loginToken, let transactionId):
canOpenLink = self.legacyAppDelegate.continueSSOLogin(withToken: loginToken, txnId: transactionId)
case .connect(let loginToken, let transactionID):
canOpenLink = AuthenticationService.shared.continueSSOLogin(with: loginToken, and: transactionID)
}
return canOpenLink

View File

@@ -282,14 +282,6 @@ UINavigationControllerDelegate
*/
- (void)checkAppVersion;
#pragma mark - Authentication
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
/// @param loginToken The login token provided when SSO succeeded.
/// @param txnId transaction id generated during SSO page presentation.
/// returns YES if the SSO login can be continued.
- (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId;
@end
@protocol LegacyAppDelegateDelegate <NSObject>

View File

@@ -2430,6 +2430,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
}
BOOL isLaunching = NO;
if (mainSession.vc_homeserverConfiguration)
{
[MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy = mainSession.vc_homeserverConfiguration.encryption.outboundKeysPreSharingMode;
}
if (_masterTabBarController.isOnboardingInProgress)
{
@@ -4696,21 +4701,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
[self presentViewController:viewController animated:YES completion:completion];
}
#pragma mark - Authentication
- (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId
{
OnboardingCoordinatorBridgePresenter *bridgePresenter = self.masterTabBarController.onboardingCoordinatorBridgePresenter;
if (!bridgePresenter)
{
MXLogDebug(@"[AppDelegate] Fail to continue SSO login");
return NO;
}
return [bridgePresenter continueSSOLoginWithToken:loginToken transactionID:txnId];
}
#pragma mark - Private
- (void)clearCache

View File

@@ -50,9 +50,6 @@ protocol AuthenticationCoordinatorProtocol: Coordinator, Presentable {
/// Set up the authentication screen with the specified homeserver and/or identity server.
func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?)
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool
/// Indicates to the coordinator to display any pending screens if it was created with
/// the `canPresentAdditionalScreens` parameter set to `false`
func presentPendingScreensIfNecessary()

View File

@@ -554,6 +554,12 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0;
- (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId
{
// The presenter isn't dismissed automatically when finishing via a deep link
if (self.ssoAuthenticationPresenter)
{
[self dismissSSOAuthenticationPresenter];
}
// Check if transaction id is the same as expected
if (loginToken &&
txnId && self.ssoCallbackTxnId

View File

@@ -73,8 +73,10 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator
// MARK: - Public
func start() {
// Listen to the end of the authentication flow
// Listen to the end of the authentication flow.
authenticationViewController.authVCDelegate = self
// Listen for changes from deep links.
AuthenticationService.shared.delegate = self
}
func toPresentable() -> UIViewController {
@@ -97,10 +99,6 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator
authenticationViewController.showCustomHomeserver(homeserver, andIdentityServer: identityServer)
}
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool {
authenticationViewController.continueSSOLogin(withToken: loginToken, txnId: transactionID)
}
func presentPendingScreensIfNecessary() {
canPresentAdditionalScreens = true
@@ -147,6 +145,13 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator
}
}
// MARK: - AuthenticationServiceDelegate
extension LegacyAuthenticationCoordinator: AuthenticationServiceDelegate {
func authenticationService(_ service: AuthenticationService, didReceive ssoLoginToken: String, with transactionID: String) -> Bool {
authenticationViewController.continueSSOLogin(withToken: ssoLoginToken, txnId: transactionID)
}
}
// MARK: - AuthenticationViewControllerDelegate
extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate {
func authenticationViewController(_ authenticationViewController: AuthenticationViewController,

View File

@@ -72,17 +72,12 @@ final class SSOAuthenticationPresenter: NSObject {
self.identityProvider = identityProvider
self.presentingViewController = presentingViewController
// NOTE: By using SFAuthenticationSession the consent alert show product name instead of display name. Fallback to SFSafariViewController instead in order to not disturb users with "Riot" wording at the moment.
// (https://stackoverflow.com/questions/49860338/why-does-sfauthenticationsession-consent-alert-show-xcode-project-name-instead-o)
if #available(iOS 13.0, *) {
if #unavailable(iOS 15.0), UIAccessibility.isGuidedAccessEnabled {
// SFAuthenticationSession and ASWebAuthenticationSession doesn't work with guided access (rdar://48376122)
if UIAccessibility.isGuidedAccessEnabled {
self.presentSafariViewController(with: authenticationURL, animated: animated)
} else {
self.startAuthenticationSession(with: authenticationURL)
}
// Confirmed to be fixed on iOS 15, haven't been able to test on iOS 14.
presentSafariViewController(with: authenticationURL, animated: animated)
} else {
self.presentSafariViewController(with: authenticationURL, animated: animated)
startAuthenticationSession(with: authenticationURL)
}
}

View File

@@ -42,10 +42,22 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
private let navigationRouter: NavigationRouterType
private let authenticationService = AuthenticationService.shared
/// The initial screen to be shown when starting the coordinator.
private let initialScreen: EntryPoint
/// The type of authentication that was used to complete the flow.
private var authenticationType: AuthenticationType?
/// The presenter used to handler authentication via SSO.
private var ssoAuthenticationPresenter: SSOAuthenticationPresenter?
/// The transaction ID used when presenting the SSO screen. Used when completing via a deep link.
private var ssoTransactionID: String?
/// Whether the coordinator can present further screens after a successful login has occurred.
private var canPresentAdditionalScreens: Bool
/// `true` if presentation of the verification screen is blocked by `canPresentAdditionalScreens`.
private var isWaitingToPresentCompleteSecurity = false
/// The listener object that informs the coordinator whether verification needs to be presented or not.
private var verificationListener: SessionVerificationListener?
/// The password entered, for use when setting up cross-signing.
@@ -72,9 +84,10 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
// MARK: - Public
func start() {
Task {
Task { @MainActor in
await startAuthenticationFlow()
await MainActor.run { callback?(.didStart) }
callback?(.didStart)
authenticationService.delegate = self
}
}
@@ -97,23 +110,33 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
/// Starts the authentication flow.
@MainActor private func startAuthenticationFlow() async {
do {
let flow: AuthenticationFlow = initialScreen == .login ? .login : .register
let homeserverAddress = authenticationService.state.homeserver.addressFromUser ?? authenticationService.state.homeserver.address
try await authenticationService.startFlow(flow, for: homeserverAddress)
} catch {
MXLog.error("[AuthenticationCoordinator] start: Failed to start")
displayError(message: error.localizedDescription)
return
let flow: AuthenticationFlow = initialScreen == .login ? .login : .register
if initialScreen != .selectServerForRegistration {
do {
let homeserverAddress = authenticationService.state.homeserver.addressFromUser ?? authenticationService.state.homeserver.address
try await authenticationService.startFlow(flow, for: homeserverAddress)
} catch {
MXLog.error("[AuthenticationCoordinator] start: Failed to start")
displayError(message: error.localizedDescription)
return
}
}
switch initialScreen {
case .registration:
showRegistrationScreen()
if authenticationService.state.homeserver.needsRegistrationFallback {
showFallback(for: flow)
} else {
showRegistrationScreen()
}
case .selectServerForRegistration:
showServerSelectionScreen()
case .login:
showLoginScreen()
if authenticationService.state.homeserver.needsLoginFallback {
showFallback(for: flow)
} else {
showLoginScreen()
}
}
}
@@ -144,12 +167,56 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
callback?(.cancel(.register))
}
// MARK: - Login
/// Shows the login screen.
@MainActor private func showLoginScreen() {
MXLog.debug("[AuthenticationCoordinator] showLoginScreen")
let homeserver = authenticationService.state.homeserver
let parameters = AuthenticationLoginCoordinatorParameters(navigationRouter: navigationRouter,
authenticationService: authenticationService,
loginMode: homeserver.preferredLoginMode)
let coordinator = AuthenticationLoginCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return }
self.loginCoordinator(coordinator, didCallbackWith: 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 based on the result from the registration screen.
@MainActor private func loginCoordinator(_ coordinator: AuthenticationLoginCoordinator,
didCallbackWith result: AuthenticationLoginCoordinatorResult) {
switch result {
case .continueWithSSO(let provider):
presentSSOAuthentication(for: provider)
case .success(let session, let loginPassword):
password = loginPassword
authenticationType = .password
onSessionCreated(session: session, flow: .login)
case .fallback:
showFallback(for: .login)
}
}
// 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,
flow: .register,
hasModalPresentation: false)
let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] result in
@@ -176,7 +243,11 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) {
switch result {
case .updated:
showRegistrationScreen()
if authenticationService.state.homeserver.needsRegistrationFallback {
showFallback(for: .register)
} else {
showRegistrationScreen()
}
case .dismiss:
MXLog.failure("[AuthenticationCoordinator] AuthenticationServerSelectionScreen is requesting dismiss when part of a stack.")
}
@@ -193,7 +264,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
let coordinator = AuthenticationRegistrationCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return }
self.registrationCoordinator(coordinator, didCompleteWith: result)
self.registrationCoordinator(coordinator, didCallbackWith: result)
}
coordinator.start()
@@ -208,12 +279,18 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
}
}
/// Displays the next view in the flow after the registration screen.
/// Displays the next view in the flow based on the result from the registration screen.
@MainActor private func registrationCoordinator(_ coordinator: AuthenticationRegistrationCoordinator,
didCompleteWith result: AuthenticationRegistrationCoordinatorResult) {
didCallbackWith result: AuthenticationRegistrationCoordinatorResult) {
switch result {
case .completed(let result):
case .continueWithSSO(let provider):
presentSSOAuthentication(for: provider)
case .completed(let result, let registerPassword):
password = registerPassword
authenticationType = .password
handleRegistrationResult(result)
case .fallback:
showFallback(for: .register)
}
}
@@ -243,7 +320,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
let localizedPolicies = terms?.policiesData(forLanguage: Bundle.mxk_language(), defaultLanguage: Bundle.mxk_fallbackLanguage())
let parameters = AuthenticationTermsCoordinatorParameters(registrationWizard: registrationWizard,
localizedPolicies: localizedPolicies ?? [],
homeserverAddress: homeserver.addressFromUser ?? homeserver.address)
homeserverAddress: homeserver.displayableAddress)
let coordinator = AuthenticationTermsCoordinator(parameters: parameters)
coordinator.callback = { [weak self] result in
self?.registrationStageDidComplete(with: result)
@@ -310,12 +387,6 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
}
}
/// Shows the login screen.
@MainActor private func showLoginScreen() {
MXLog.debug("[AuthenticationCoordinator] showLoginScreen")
}
// MARK: - Registration Handlers
/// Determines the next screen to show from the flow result and pushes it.
@MainActor private func handleRegistrationResult(_ result: RegistrationResult) {
@@ -354,15 +425,14 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
case .dummy:
MXLog.failure("[AuthenticationCoordinator] Attempting to perform the dummy stage.")
case .other:
#warning("Show fallback")
MXLog.failure("[AuthenticationCoordinator] Attempting to perform an unsupported stage.")
showFallback(for: .register)
}
}
/// Handles the creation of a new session following on from a successful authentication.
@MainActor private func onSessionCreated(session: MXSession, flow: AuthenticationFlow) {
self.session = session
// self.password = password
if canPresentAdditionalScreens {
showLoadingAnimation()
@@ -390,11 +460,38 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
verificationListener.start()
self.verificationListener = verificationListener
#warning("Add authentication type to the new flow")
callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: .other))
callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: authenticationType ?? .other))
}
// MARK: - Additional Screens
private func showFallback(for flow: AuthenticationFlow) {
let url = authenticationService.fallbackURL(for: flow)
MXLog.debug("[AuthenticationCoordinator] showFallback for: \(flow), url: \(url)")
guard let fallbackVC = AuthFallBackViewController(url: url.absoluteString) else {
MXLog.error("[AuthenticationCoordinator] showFallback: could not create fallback view controller")
return
}
fallbackVC.delegate = self
let navController = RiotNavigationController(rootViewController: fallbackVC)
navController.navigationBar.topItem?.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
target: self,
action: #selector(dismissFallback))
navigationRouter.present(navController, animated: true)
}
@objc
private func dismissFallback() {
MXLog.debug("[AuthenticationCoorrdinator] dismissFallback")
guard let fallbackNavigationVC = navigationRouter.toPresentable().presentedViewController as? RiotNavigationController else {
return
}
fallbackNavigationVC.dismiss(animated: true)
authenticationService.reset()
}
/// Replace the contents of the navigation router with a loading animation.
private func showLoadingAnimation() {
@@ -409,7 +506,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
/// Present the key verification screen modally.
private func presentCompleteSecurity() {
guard let session = session else {
MXLog.error("[LegacyAuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.")
MXLog.error("[AuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.")
authenticationDidComplete()
return
}
@@ -434,12 +531,96 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
}
}
// MARK: - SSO
extension AuthenticationCoordinator: SSOAuthenticationPresenterDelegate {
/// Presents SSO authentication for the specified identity provider.
@MainActor private func presentSSOAuthentication(for identityProvider: SSOIdentityProvider) {
let service = SSOAuthenticationService(homeserverStringURL: authenticationService.state.homeserver.address)
let presenter = SSOAuthenticationPresenter(ssoAuthenticationService: service)
presenter.delegate = self
let transactionID = MXTools.generateTransactionId()
presenter.present(forIdentityProvider: identityProvider, with: transactionID, from: toPresentable(), animated: true)
ssoAuthenticationPresenter = presenter
ssoTransactionID = transactionID
authenticationType = .sso(identityProvider)
}
func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationSucceededWithToken token: String, usingIdentityProvider identityProvider: SSOIdentityProvider?) {
MXLog.debug("[AuthenticationCoordinator] SSO authentication succeeded.")
guard let loginWizard = authenticationService.loginWizard else {
MXLog.failure("[AuthenticationCoordinator] The login wizard was requested before getting the login flow.")
return
}
Task { await handleLoginToken(token, using: loginWizard) }
}
func ssoAuthenticationPresenter(_ presenter: SSOAuthenticationPresenter, authenticationDidFailWithError error: Error) {
MXLog.debug("[AuthenticationCoordinator] SSO authentication failed.")
Task { @MainActor in
displayError(message: error.localizedDescription)
ssoAuthenticationPresenter = nil
ssoTransactionID = nil
authenticationType = nil
}
}
func ssoAuthenticationPresenterDidCancel(_ presenter: SSOAuthenticationPresenter) {
MXLog.debug("[AuthenticationCoordinator] SSO authentication cancelled.")
ssoAuthenticationPresenter = nil
ssoTransactionID = nil
authenticationType = nil
}
/// Performs the last step of the login process for a flow that authenticated via SSO.
@MainActor private func handleLoginToken(_ token: String, using loginWizard: LoginWizard) async {
do {
let session = try await loginWizard.login(with: token)
onSessionCreated(session: session, flow: authenticationService.state.flow)
} catch {
MXLog.error("[AuthenticationCoordinator] Login with SSO token failed.")
displayError(message: error.localizedDescription)
authenticationType = nil
}
ssoAuthenticationPresenter = nil
ssoTransactionID = nil
}
}
// MARK: - AuthenticationServiceDelegate
extension AuthenticationCoordinator: AuthenticationServiceDelegate {
func authenticationService(_ service: AuthenticationService, didReceive ssoLoginToken: String, with transactionID: String) -> Bool {
guard let presenter = ssoAuthenticationPresenter, transactionID == ssoTransactionID else {
Task { await displayError(message: VectorL10n.errorCommonMessage) }
return false
}
guard let loginWizard = authenticationService.loginWizard else {
MXLog.failure("[AuthenticationCoordinator] The login wizard was requested before getting the login flow.")
return false
}
Task {
await handleLoginToken(ssoLoginToken, using: loginWizard)
await MainActor.run { presenter.dismiss(animated: true, completion: nil) }
}
return true
}
}
// MARK: - KeyVerificationCoordinatorDelegate
extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate {
func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) {
if let crypto = session?.crypto,
!crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled {
MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys")
MXLog.debug("[AuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys")
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
}
@@ -487,9 +668,24 @@ extension AuthenticationCoordinator {
func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?) {
// unused
}
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool {
#warning("To be implemented elsewhere")
return false
}
}
// MARK: - AuthFallBackViewControllerDelegate
extension AuthenticationCoordinator: AuthFallBackViewControllerDelegate {
func authFallBackViewController(_ authFallBackViewController: AuthFallBackViewController,
didLoginWith loginResponse: MXLoginResponse) {
let credentials = MXCredentials(loginResponse: loginResponse, andDefaultCredentials: nil)
let client = MXRestClient(credentials: credentials)
guard let session = MXSession(matrixRestClient: client) else {
MXLog.failure("[AuthenticationCoordinator] authFallBackViewController:didLogin: session could not be created")
return
}
authenticationType = .other
Task { await onSessionCreated(session: session, flow: authenticationService.state.flow) }
}
func authFallBackViewControllerDidClose(_ authFallBackViewController: AuthFallBackViewController) {
dismissFallback()
}
}

View File

@@ -54,8 +54,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
private var navigationRouter: NavigationRouterType {
parameters.router
}
// Keep a strong ref as we need to init authVC early to preload its view
private let authenticationCoordinator: AuthenticationCoordinatorProtocol
/// A strong ref to the legacy authVC as we need to init early to preload its view.
private let legacyAuthenticationCoordinator: LegacyAuthenticationCoordinator
/// The currently active authentication coordinator, otherwise `nil`.
private weak var authenticationCoordinator: AuthenticationCoordinatorProtocol?
#warning("This might be removable when SSO comes through the AuthenticationService?")
/// A boolean to prevent authentication being shown when already in progress.
private var isShowingLegacyAuthentication = false
@@ -90,9 +92,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
init(parameters: OnboardingCoordinatorParameters) {
self.parameters = parameters
// Preload the authVC (it is *really* slow to load in realtime)
// Preload the legacy authVC (it is *really* slow to load in realtime)
let authenticationParameters = LegacyAuthenticationCoordinatorParameters(navigationRouter: parameters.router, canPresentAdditionalScreens: false)
authenticationCoordinator = LegacyAuthenticationCoordinator(parameters: authenticationParameters)
legacyAuthenticationCoordinator = LegacyAuthenticationCoordinator(parameters: authenticationParameters)
super.init()
}
@@ -116,20 +118,14 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
/// For more information see `AuthenticationViewController.externalRegistrationParameters`.
func update(externalRegistrationParameters: [AnyHashable: Any]) {
self.externalRegistrationParameters = externalRegistrationParameters
authenticationCoordinator.update(externalRegistrationParameters: externalRegistrationParameters)
legacyAuthenticationCoordinator.update(externalRegistrationParameters: externalRegistrationParameters)
}
/// Set up the authentication screen with the specified homeserver and/or identity server.
func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?) {
self.customHomeserver = homeserver
self.customIdentityServer = identityServer
authenticationCoordinator.updateHomeserver(homeserver, andIdentityServer: identityServer)
}
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool {
guard isShowingLegacyAuthentication else { return false }
return authenticationCoordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID)
legacyAuthenticationCoordinator.updateHomeserver(homeserver, andIdentityServer: identityServer)
}
// MARK: - Pre-Authentication
@@ -156,14 +152,19 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
private func splashScreenCoordinator(_ coordinator: OnboardingSplashScreenCoordinator, didCompleteWith result: OnboardingSplashScreenViewModelResult) {
splashScreenResult = result
// Set the auth type early to allow network requests to finish during display of the use case screen.
authenticationCoordinator.update(authenticationFlow: result.flow)
// Set the auth type early on the legacy auth to allow network requests to finish during display of the use case screen.
legacyAuthenticationCoordinator.update(authenticationFlow: result.flow)
switch result {
case .register:
showUseCaseSelectionScreen()
case .login:
showLegacyAuthenticationScreen()
if BuildSettings.onboardingEnableNewAuthenticationFlow {
beginAuthentication(with: .login, onStart: coordinator.stop)
} else {
coordinator.stop()
showLegacyAuthenticationScreen()
}
}
}
@@ -232,6 +233,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
self.cancelAuthentication(flow: flow)
}
}
authenticationCoordinator = coordinator
add(childCoordinator: coordinator)
coordinator.start()
@@ -243,7 +245,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
MXLog.debug("[OnboardingCoordinator] showLegacyAuthenticationScreen")
let coordinator = authenticationCoordinator
let coordinator = legacyAuthenticationCoordinator
coordinator.callback = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return }
@@ -256,7 +258,6 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
// These results are only sent by the new flow.
break
}
}
// Due to needing to preload the authVC, this breaks the Coordinator init/start pattern.
@@ -272,6 +273,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
coordinator.update(softLogoutCredentials: softLogoutCredentials)
}
authenticationCoordinator = coordinator
coordinator.start()
add(childCoordinator: coordinator)
@@ -567,7 +570,12 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
}
guard authenticationFinished else {
MXLog.debug("[OnboardingCoordinator] Allowing LegacyAuthenticationCoordinator to display any remaining screens.")
guard let authenticationCoordinator = authenticationCoordinator else {
MXLog.failure("[OnboardingCoordinator] completeIfReady: authenticationCoordinator is missing.")
return
}
MXLog.debug("[OnboardingCoordinator] Allowing AuthenticationCoordinator to display any remaining screens.")
authenticationCoordinator.presentPendingScreensIfNecessary()
return
}

View File

@@ -97,12 +97,6 @@ final class OnboardingCoordinatorBridgePresenter: NSObject {
coordinator?.updateHomeserver(homeserver, andIdentityServer: identityServer)
}
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool {
guard let coordinator = coordinator else { return false }
return coordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID)
}
func dismiss(animated: Bool, completion: (() -> Void)?) {
guard let coordinator = self.coordinator else {
return

View File

@@ -27,7 +27,4 @@ protocol OnboardingCoordinatorProtocol: Coordinator, Presentable {
/// Set up the authentication screen with the specified homeserver and/or identity server.
func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?)
/// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters.
func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool
}

View File

@@ -1903,6 +1903,11 @@
return;
}
__block UserIndicatorCancel cancelIndicator;
NSTimer *indicatorTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:NO block:^(NSTimer * _Nonnull timer) {
cancelIndicator = [self.userIndicatorStore presentLoadingWithLabel:[VectorL10n homeSyncing] isInteractionBlocking:NO];
}];
// Store the current height of the first bubble (if any)
backPaginationSavedFirstBubbleHeight = 0;
if (direction == MXTimelineDirectionBackwards && [roomDataSource tableView:_bubblesTableView numberOfRowsInSection:0])
@@ -1987,6 +1992,12 @@
{
[self updateCurrentEventIdAtTableBottom:NO];
}
[indicatorTimer invalidate];
if (cancelIndicator) {
cancelIndicator();
}
} failure:^(NSError *error) {
@@ -2002,6 +2013,11 @@
self.bubbleTableViewDisplayInTransition = NO;
[indicatorTimer invalidate];
if (cancelIndicator) {
cancelIndicator();
}
}];
}

View File

@@ -0,0 +1,78 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
/// Information about a homeserver that is ready for display in the authentication flow.
struct AuthenticationHomeserverViewData: Equatable {
/// The homeserver string to be shown to the user.
let address: String
/// Whether or not the homeserver is matrix.org.
let isMatrixDotOrg: Bool
/// Whether or not to display the username and password text fields during login.
let showLoginForm: Bool
/// Whether or not to display the username and password text fields during registration.
let showRegistrationForm: Bool
/// The supported SSO login options.
let ssoIdentityProviders: [SSOIdentityProvider]
}
// MARK: - Mocks
extension AuthenticationHomeserverViewData {
/// A mock homeserver that is configured just like matrix.org.
static var mockMatrixDotOrg: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "matrix.org",
isMatrixDotOrg: true,
showLoginForm: true,
showRegistrationForm: true,
ssoIdentityProviders: [
SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil),
SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil),
SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil),
SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil),
SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil)
])
}
/// A mock homeserver that supports login and registration via a password but has no SSO providers.
static var mockBasicServer: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "example.com",
isMatrixDotOrg: false,
showLoginForm: true,
showRegistrationForm: true,
ssoIdentityProviders: [])
}
/// A mock homeserver that supports only supports authentication via a single SSO provider.
static var mockEnterpriseSSO: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "company.com",
isMatrixDotOrg: false,
showLoginForm: false,
showRegistrationForm: false,
ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)])
}
/// A mock homeserver that supports only supports authentication via fallback.
static var mockFallback: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "company.com",
isMatrixDotOrg: false,
showLoginForm: false,
showRegistrationForm: false,
ssoIdentityProviders: [])
}
}

View File

@@ -78,6 +78,13 @@ enum LoginError: String, Error {
case resetPasswordNotStarted
}
struct HomeserverAddress {
/// Ensures the address contains a scheme, otherwise makes it `https`.
static func sanitized(_ address: String) -> String {
!address.contains("://") ? "https://\(address.lowercased())" : address.lowercased()
}
}
/// Represents an SSO Identity Provider as provided in a login flow.
@objc class SSOIdentityProvider: NSObject, Identifiable {
/// The id field is the Identity Provider identifier used for the SSO Web page redirection `/login/sso/redirect/{idp_id}`.

View File

@@ -16,7 +16,7 @@
import SwiftUI
/// An button that displays the icon and name of an SSO provider.
/// A button that displays the icon and name of an SSO provider.
struct AuthenticationSSOButton: View {
// MARK: - Constants
@@ -28,6 +28,11 @@ struct AuthenticationSSOButton: View {
// MARK: - Private
@Environment(\.theme) private var theme
@ScaledMetric private var iconSize = 24
private var renderingMode: Image.TemplateRenderingMode? {
provider.brand == Brand.apple.rawValue || provider.brand == Brand.github.rawValue ? .template : nil
}
// MARK: - Public
@@ -52,32 +57,63 @@ struct AuthenticationSSOButton: View {
.opacity(0)
}
.frame(maxWidth: .infinity)
.fixedSize(horizontal: false, vertical: true)
.contentShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(SecondaryActionButtonStyle(customColor: theme.colors.quinaryContent))
}
@ViewBuilder
/// The icon with appropriate rendering mode and size for dynamic type.
var icon: some View {
iconImage.map { image in
image
.renderingMode(renderingMode)
.resizable()
.scaledToFit()
.frame(width: iconSize, height: iconSize)
.foregroundColor(renderingMode == .template ? theme.colors.primaryContent : nil)
}
}
/// The image to be shown in the icon.
var iconImage: Image? {
switch provider.brand {
case Brand.apple.rawValue:
Image(Asset.Images.authenticationSsoIconApple.name)
.renderingMode(.template)
.foregroundColor(theme.colors.primaryContent)
return Image(Asset.Images.authenticationSsoIconApple.name)
case Brand.facebook.rawValue:
Image(Asset.Images.authenticationSsoIconFacebook.name)
return Image(Asset.Images.authenticationSsoIconFacebook.name)
case Brand.github.rawValue:
Image(Asset.Images.authenticationSsoIconGithub.name)
.renderingMode(.template)
.foregroundColor(theme.colors.primaryContent)
return Image(Asset.Images.authenticationSsoIconGithub.name)
case Brand.gitlab.rawValue:
Image(Asset.Images.authenticationSsoIconGitlab.name)
return Image(Asset.Images.authenticationSsoIconGitlab.name)
case Brand.google.rawValue:
Image(Asset.Images.authenticationSsoIconGoogle.name)
return Image(Asset.Images.authenticationSsoIconGoogle.name)
case Brand.twitter.rawValue:
Image(Asset.Images.authenticationSsoIconTwitter.name)
return Image(Asset.Images.authenticationSsoIconTwitter.name)
default:
EmptyView()
return nil
}
}
}
struct AuthenticationSSOButton_Previews: PreviewProvider {
static var matrixDotOrg = AuthenticationHomeserverViewData.mockMatrixDotOrg
static var buttons: some View {
VStack {
ForEach(matrixDotOrg.ssoIdentityProviders) { provider in
AuthenticationSSOButton(provider: provider) { }
}
AuthenticationSSOButton(provider: SSOIdentityProvider(id: "", name: "SAML", brand: nil, iconURL: nil)) { }
}
.padding()
}
static var previews: some View {
buttons
.theme(.light).preferredColorScheme(.light)
.environment(\.sizeCategory, .accessibilityLarge)
buttons
.theme(.dark).preferredColorScheme(.dark)
}
}

View File

@@ -0,0 +1,67 @@
//
// 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
/// A view that shows information about the chosen homeserver,
/// along with an edit button to pick a different one.
struct AuthenticationServerInfoSection: View {
// MARK: - Private
@Environment(\.theme) private var theme
// MARK: - Public
let address: String
let showMatrixDotOrgInfo: Bool
let editAction: () -> Void
// MARK: - Views
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(VectorL10n.authenticationServerInfoTitle)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.secondaryContent)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(address)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
if showMatrixDotOrgInfo {
Text(VectorL10n.authenticationServerInfoMatrixDescription)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.tertiaryContent)
.accessibilityIdentifier("serverDescriptionText")
}
}
Spacer()
Button(action: editAction) {
Text(VectorL10n.edit)
.font(theme.fonts.body)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent))
}
}
}
}
}

View File

@@ -17,7 +17,14 @@
import Foundation
protocol AuthenticationServiceDelegate: AnyObject {
func authenticationServiceDidUpdateRegistrationParameters(_ authenticationService: AuthenticationService)
/// The authentication service received an SSO login token via a deep link.
/// This only occurs when SSOAuthenticationPresenter uses an SFSafariViewController.
/// - Parameters:
/// - service: The authentication service.
/// - ssoLoginToken: The login token provided when SSO succeeded.
/// - transactionID: The transaction ID generated during SSO page presentation.
/// - Returns: `true` if the SSO login can be continued.
func authenticationService(_ service: AuthenticationService, didReceive ssoLoginToken: String, with transactionID: String) -> Bool
}
class AuthenticationService: NSObject {
@@ -43,6 +50,9 @@ class AuthenticationService: NSObject {
/// The current registration wizard or `nil` if `startFlow` hasn't been called for `.registration`.
private(set) var registrationWizard: RegistrationWizard?
/// The authentication service's delegate.
weak var delegate: AuthenticationServiceDelegate?
// MARK: - Setup
override init() {
@@ -84,14 +94,7 @@ class AuthenticationService: NSObject {
}
func startFlow(_ flow: AuthenticationFlow, for homeserverAddress: String) async throws {
reset()
let loginFlows = try await loginFlow(for: homeserverAddress)
state.homeserver = .init(address: loginFlows.homeserverAddress,
addressFromUser: homeserverAddress,
preferredLoginMode: loginFlows.loginMode,
loginModeSupportedTypes: loginFlows.supportedLoginTypes)
var (client, homeserver) = try await loginFlow(for: homeserverAddress)
let loginWizard = LoginWizard(client: client)
self.loginWizard = loginWizard
@@ -99,22 +102,20 @@ class AuthenticationService: NSObject {
if flow == .register {
do {
let registrationWizard = RegistrationWizard(client: client)
state.homeserver.registrationFlow = try await registrationWizard.registrationFlow()
homeserver.registrationFlow = try await registrationWizard.registrationFlow()
self.registrationWizard = registrationWizard
} catch {
guard state.homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else {
guard homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else {
throw error
}
// Continue without throwing when registration is disabled but SSO is available.
}
}
state.flow = flow
}
/// Get a SSO url
func getSSOURL(redirectUrl: String, deviceId: String?, providerId: String?) -> String? {
fatalError("Not implemented.")
// The state and client are set after trying the registration flow to
// ensure the existing state isn't wiped out when an error occurs.
self.state = AuthenticationState(flow: flow, homeserver: homeserver)
self.client = client
}
/// Get the sign in or sign up fallback URL
@@ -136,15 +137,19 @@ class AuthenticationService: NSObject {
func reset() {
loginWizard = nil
registrationWizard = nil
// The previously used homeserver is re-used as `startFlow` will be called again a replace it anyway.
let address = state.homeserver.addressFromUser ?? state.homeserver.address
self.state = AuthenticationState(flow: .login, homeserverAddress: address)
}
/// Create a session after a SSO successful login
func makeSessionFromSSO(credentials: MXCredentials) -> MXSession {
sessionCreator.createSession(credentials: credentials, client: client)
/// Continues an SSO flow when completion comes via a deep link.
/// - Parameters:
/// - token: The login token provided when SSO succeeded.
/// - transactionID: The transaction ID generated during SSO page presentation.
/// - Returns: `true` if the SSO login can be continued.
func continueSSOLogin(with token: String, and transactionID: String) -> Bool {
delegate?.authenticationService(self, didReceive: token, with: transactionID) ?? false
}
// /// Perform a well-known request, using the domain from the matrixId
@@ -170,10 +175,11 @@ class AuthenticationService: NSObject {
// MARK: - Private
/// Request the supported login flows for this homeserver.
/// Query the supported login flows for the supplied homeserver.
/// This is the first method to call to be able to get a wizard to login or to create an account
/// - Parameter homeserverAddress: The homeserver string entered by the user.
private func loginFlow(for homeserverAddress: String) async throws -> LoginFlowResult {
/// - Returns: A tuple containing the REST client for the server along with the homeserver state containing the login flows.
private func loginFlow(for homeserverAddress: String) async throws -> (AuthenticationRestClient, AuthenticationState.Homeserver) {
let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress)
guard var homeserverURL = URL(string: homeserverAddress) else {
@@ -181,8 +187,6 @@ class AuthenticationService: NSObject {
throw AuthenticationError.invalidHomeserver
}
let state = AuthenticationState(flow: .login, homeserverAddress: homeserverAddress)
if let wellKnown = try? await wellKnown(for: homeserverURL),
let baseURL = URL(string: wellKnown.homeServer.baseUrl) {
homeserverURL = baseURL
@@ -193,28 +197,26 @@ class AuthenticationService: NSObject {
let loginFlow = try await getLoginFlowResult(client: client)
self.client = client
self.state = state
return loginFlow
let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress,
addressFromUser: homeserverAddress,
preferredLoginMode: loginFlow.loginMode)
return (client, homeserver)
}
/// Request the supported login flows for the corresponding session.
/// This method is used to get the flows for a server after a soft-logout.
/// - Parameter session: The MXSession where a soft-logout has occurred.
private func loginFlow(for session: MXSession) async throws -> LoginFlowResult {
private func loginFlow(for session: MXSession) async throws -> (AuthenticationRestClient, AuthenticationState.Homeserver) {
guard let client = session.matrixRestClient else {
MXLog.error("[AuthenticationService] loginFlow called on a session that doesn't have a matrixRestClient.")
throw AuthenticationError.missingMXRestClient
}
let state = AuthenticationState(flow: .login, homeserverAddress: client.homeserver)
let loginFlow = try await getLoginFlowResult(client: session.matrixRestClient)
self.client = client
self.state = state
return loginFlow
let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress,
preferredLoginMode: loginFlow.loginMode)
return (client, homeserver)
}
private func getLoginFlowResult(client: MXRestClient) async throws -> LoginFlowResult {

View File

@@ -30,16 +30,24 @@ struct AuthenticationState {
self.homeserver = Homeserver(address: homeserverAddress)
}
init(flow: AuthenticationFlow, homeserver: Homeserver) {
self.flow = flow
self.homeserver = homeserver
}
struct Homeserver {
/// The homeserver address as returned by the server.
var address: String
/// The homeserver address as input by the user (it can differ to the well-known request).
var addressFromUser: String?
/// The homeserver's address formatted to be displayed to the user in labels, text fields etc.
var displayableAddress: String {
let address = addressFromUser ?? address
return address.replacingOccurrences(of: "https://", with: "") // Only remove https. Leave http to indicate the server doesn't use SSL.
}
/// The preferred login mode for the server
var preferredLoginMode: LoginMode = .unknown
/// Supported types for the login.
var loginModeSupportedTypes = [MXLoginFlow]()
/// The response returned when querying the homeserver for registration flows.
var registrationFlow: RegistrationResult?
@@ -49,5 +57,32 @@ struct AuthenticationState {
guard let url = URL(string: address) else { return false }
return url.host == "matrix.org" || url.host == "matrix-client.matrix.org"
}
/// The homeserver mapped into view data that is ready for display.
var viewData: AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: displayableAddress,
isMatrixDotOrg: isMatrixDotOrg,
showLoginForm: preferredLoginMode.supportsPasswordFlow,
showRegistrationForm: registrationFlow != nil && !needsRegistrationFallback,
ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? [])
}
/// Needs authentication fallback for login
var needsLoginFallback: Bool {
return preferredLoginMode.isUnsupported
}
/// Needs authentication fallback for registration
var needsRegistrationFallback: Bool {
guard let flow = registrationFlow else {
return false
}
switch flow {
case .flowResponse(let result):
return result.needsFallback
default:
return false
}
}
}
}

View File

@@ -75,6 +75,15 @@ enum LoginMode {
return false
}
}
var isUnsupported: Bool {
switch self {
case .unsupported:
return true
default:
return false
}
}
}
/// Data obtained when calling `LoginWizard.resetPassword` that will be used

View File

@@ -0,0 +1,88 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
// MARK: View model
enum AuthenticationLoginViewModelResult {
/// The user would like to select another server.
case selectServer
/// Parse the username and update the homeserver if included.
case parseUsername(String)
/// The user would like to reset their password.
case forgotPassword
/// Login using the supplied credentials.
case login(username: String, password: String)
/// Continue using the supplied SSO provider.
case continueWithSSO(SSOIdentityProvider)
/// Continue using the fallback page
case fallback
}
// MARK: View
struct AuthenticationLoginViewState: BindableState {
/// Data about the selected homeserver.
var homeserver: AuthenticationHomeserverViewData
/// Whether a new homeserver is currently being loaded.
var isLoading: Bool = false
/// View state that can be bound to from SwiftUI.
var bindings: AuthenticationLoginBindings
/// Whether to show any SSO buttons.
var showSSOButtons: Bool {
!homeserver.ssoIdentityProviders.isEmpty
}
/// `true` if it is possible to continue, otherwise `false`.
var hasValidCredentials: Bool {
!bindings.username.isEmpty && !bindings.password.isEmpty
}
}
struct AuthenticationLoginBindings {
/// The username input by the user.
var username = ""
/// The password input by the user.
var password = ""
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<AuthenticationLoginErrorType>?
}
enum AuthenticationLoginViewAction {
/// The user would like to select another server.
case selectServer
/// Parse the username to detect if a homeserver is included.
case parseUsername
/// The user would like to reset their password.
case forgotPassword
/// Continue using the input username and password.
case next
/// Continue using the fallback page
case fallback
/// Continue using the supplied SSO provider.
case continueWithSSO(SSOIdentityProvider)
}
enum AuthenticationLoginErrorType: Hashable {
/// An error response from the homeserver.
case mxError(String)
/// The current homeserver address isn't valid.
case invalidHomeserver
/// The response from the homeserver was unexpected.
case unknown
}

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 SwiftUI
typealias AuthenticationLoginViewModelType = StateStoreViewModel<AuthenticationLoginViewState,
Never,
AuthenticationLoginViewAction>
class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, AuthenticationLoginViewModelProtocol {
// MARK: - Properties
// MARK: Public
var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)?
// MARK: - Setup
init(homeserver: AuthenticationHomeserverViewData) {
let bindings = AuthenticationLoginBindings()
let viewState = AuthenticationLoginViewState(homeserver: homeserver, bindings: bindings)
super.init(initialViewState: viewState)
}
// MARK: - Public
override func process(viewAction: AuthenticationLoginViewAction) {
switch viewAction {
case .selectServer:
Task { await callback?(.selectServer) }
case .parseUsername:
Task { await callback?(.parseUsername(state.bindings.username)) }
case .forgotPassword:
Task { await callback?(.forgotPassword) }
case .next:
Task { await callback?(.login(username: state.bindings.username, password: state.bindings.password)) }
case .fallback:
Task { await callback?(.fallback) }
case .continueWithSSO(let provider):
Task { await callback?(.continueWithSSO(provider))}
}
}
@MainActor func update(isLoading: Bool) {
guard state.isLoading != isLoading else { return }
state.isLoading = isLoading
}
@MainActor func update(homeserver: AuthenticationHomeserverViewData) {
state.homeserver = homeserver
}
@MainActor func displayError(_ type: AuthenticationLoginErrorType) {
switch type {
case .mxError(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
message: message)
case .invalidHomeserver:
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
message: VectorL10n.authenticationServerSelectionGenericError)
case .unknown:
state.bindings.alertInfo = AlertInfo(id: type)
}
}
}

View File

@@ -0,0 +1,35 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol AuthenticationLoginViewModelProtocol {
var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? { get set }
var context: AuthenticationLoginViewModelType.Context { get }
/// Update the view to reflect that a new homeserver is being loaded.
/// - Parameter isLoading: Whether or not the homeserver is being loaded.
@MainActor func update(isLoading: Bool)
/// Update the view with new homeserver information.
/// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`.
@MainActor func update(homeserver: AuthenticationHomeserverViewData)
/// Display an error to the user.
/// - Parameter type: The type of error to be displayed.
@MainActor func displayError(_ type: AuthenticationLoginErrorType)
}

View File

@@ -0,0 +1,247 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import CommonKit
import MatrixSDK
struct AuthenticationLoginCoordinatorParameters {
let navigationRouter: NavigationRouterType
let authenticationService: AuthenticationService
/// The login mode to allow SSO buttons to be shown when available.
let loginMode: LoginMode
}
enum AuthenticationLoginCoordinatorResult {
/// Continue using the supplied SSO provider.
case continueWithSSO(SSOIdentityProvider)
/// Login was successful with the associated session created.
case success(session: MXSession, password: String)
/// Login requested a fallback
case fallback
}
final class AuthenticationLoginCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: AuthenticationLoginCoordinatorParameters
private let authenticationLoginHostingController: VectorHostingController
private var authenticationLoginViewModel: AuthenticationLoginViewModelProtocol
private var currentTask: Task<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 login.
private var authenticationService: AuthenticationService { parameters.authenticationService }
/// The wizard used to handle the login flow. Will only be `nil` if there is a misconfiguration.
private var loginWizard: LoginWizard? { parameters.authenticationService.loginWizard }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor (AuthenticationLoginCoordinatorResult) -> Void)?
// MARK: - Setup
@MainActor init(parameters: AuthenticationLoginCoordinatorParameters) {
self.parameters = parameters
let homeserver = parameters.authenticationService.state.homeserver
let viewModel = AuthenticationLoginViewModel(homeserver: homeserver.viewData)
authenticationLoginViewModel = viewModel
let view = AuthenticationLoginScreen(viewModel: viewModel.context)
authenticationLoginHostingController = VectorHostingController(rootView: view)
authenticationLoginHostingController.vc_removeBackTitle()
authenticationLoginHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationLoginHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[AuthenticationLoginCoordinator] did start.")
Task { await setupViewModel() }
}
func toPresentable() -> UIViewController {
authenticationLoginHostingController
}
// MARK: - Private
/// Set up the view model. This method is extracted from `start()` so it can run on the `MainActor`.
@MainActor private func setupViewModel() {
authenticationLoginViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationLoginCoordinator] AuthenticationLoginViewModel did callback with result: \(result).")
switch result {
case .selectServer:
self.presentServerSelectionScreen()
case .parseUsername(let username):
self.parseUsername(username)
case .forgotPassword:
#warning("Show the forgot password flow.")
case .login(let username, let password):
self.login(username: username, password: password)
case .continueWithSSO(let identityProvider):
self.callback?(.continueWithSSO(identityProvider))
case .fallback:
self.callback?(.fallback)
}
}
}
/// Show a blocking activity indicator whilst saving.
@MainActor private func startLoading(isInteractionBlocking: Bool) {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: isInteractionBlocking))
if !isInteractionBlocking {
authenticationLoginViewModel.update(isLoading: true)
}
}
/// Hide the currently displayed activity indicator.
@MainActor private func stopLoading() {
authenticationLoginViewModel.update(isLoading: false)
waitingIndicator = nil
}
/// Login with the supplied username and password.
@MainActor private func login(username: String, password: String) {
guard let loginWizard = loginWizard else {
MXLog.failure("[AuthenticationLoginCoordinator] The login wizard was requested before getting the login flow.")
return
}
startLoading(isInteractionBlocking: true)
currentTask = Task { [weak self] in
do {
let session = try await loginWizard.login(login: username,
password: password,
initialDeviceName: UIDevice.current.initialDisplayName)
guard !Task.isCancelled else { return }
callback?(.success(session: session, password: password))
self?.stopLoading()
} catch {
self?.stopLoading()
self?.handleError(error)
}
}
}
/// Processes an error to either update the flow or display it to the user.
@MainActor private func handleError(_ error: Error) {
if let mxError = MXError(nsError: error as NSError) {
authenticationLoginViewModel.displayError(.mxError(mxError.error))
return
}
if let authenticationError = error as? AuthenticationError {
switch authenticationError {
case .invalidHomeserver:
authenticationLoginViewModel.displayError(.invalidHomeserver)
case .loginFlowNotCalled:
#warning("Reset the flow")
case .missingMXRestClient:
#warning("Forget the soft logout session")
}
return
}
authenticationLoginViewModel.displayError(.unknown)
}
@MainActor private func parseUsername(_ username: String) {
guard MXTools.isMatrixUserIdentifier(username) else { return }
let domain = username.split(separator: ":")[1]
let homeserverAddress = HomeserverAddress.sanitized(String(domain))
startLoading(isInteractionBlocking: false)
currentTask = Task { [weak self] in
do {
try await authenticationService.startFlow(.login, for: homeserverAddress)
guard !Task.isCancelled else { return }
updateViewModel()
self?.stopLoading()
} catch {
self?.stopLoading()
self?.handleError(error)
}
}
}
/// Presents the server selection screen as a modal.
@MainActor private func presentServerSelectionScreen() {
MXLog.debug("[AuthenticationLoginCoordinator] presentServerSelectionScreen")
let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService,
flow: .login,
hasModalPresentation: true)
let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return }
self.serverSelectionCoordinator(coordinator, didCompleteWith: result)
}
coordinator.start()
add(childCoordinator: coordinator)
let modalRouter = NavigationRouter()
modalRouter.setRootModule(coordinator)
navigationRouter.present(modalRouter, animated: true)
}
/// Handles the result from the server selection modal, dismissing it after updating the view.
@MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator,
didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) {
navigationRouter.dismissModule(animated: true) { [weak self] in
if result == .updated {
self?.updateViewModel()
}
self?.remove(childCoordinator: coordinator)
}
}
/// Updates the view model to reflect any changes made to the homeserver.
@MainActor private func updateViewModel() {
let homeserver = authenticationService.state.homeserver
authenticationLoginViewModel.update(homeserver: homeserver.viewData)
if homeserver.needsLoginFallback {
callback?(.fallback)
}
}
}

View File

@@ -0,0 +1,62 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockAuthenticationLoginScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case matrixDotOrg
case passwordOnly
case passwordWithCredentials
case ssoOnly
case fallback
/// The associated screen
var screenType: Any.Type {
AuthenticationLoginScreen.self
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: AuthenticationLoginViewModel
switch self {
case .matrixDotOrg:
viewModel = AuthenticationLoginViewModel(homeserver: .mockMatrixDotOrg)
case .passwordOnly:
viewModel = AuthenticationLoginViewModel(homeserver: .mockBasicServer)
case .passwordWithCredentials:
viewModel = AuthenticationLoginViewModel(homeserver: .mockBasicServer)
viewModel.context.username = "alice"
viewModel.context.password = "password"
case .ssoOnly:
viewModel = AuthenticationLoginViewModel(homeserver: .mockEnterpriseSSO)
case .fallback:
viewModel = AuthenticationLoginViewModel(homeserver: .mockFallback)
}
// can simulate service and viewModel actions here if needs be.
return (
[viewModel], AnyView(AuthenticationLoginScreen(viewModel: viewModel.context))
)
}
}

View File

@@ -0,0 +1,136 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import RiotSwiftUI
class AuthenticationLoginUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockAuthenticationLoginScreenState.self
}
override class func createTest() -> MockScreenTest {
return AuthenticationLoginUITests(selector: #selector(verifyAuthenticationLoginScreen))
}
func verifyAuthenticationLoginScreen() throws {
guard let screenState = screenState as? MockAuthenticationLoginScreenState else { fatalError("no screen") }
switch screenState {
case .matrixDotOrg:
let state = "matrix.org"
validateServerDescriptionIsVisible(for: state)
validateLoginFormIsVisible(for: state)
validateSSOButtonsAreShown(for: state)
case .passwordOnly:
let state = "a password only server"
validateServerDescriptionIsHidden(for: state)
validateLoginFormIsVisible(for: state)
validateSSOButtonsAreHidden(for: state)
validateNextButtonIsDisabled(for: state)
case .passwordWithCredentials:
let state = "a password only server with credentials entered"
validateNextButtonIsEnabled(for: state)
case .ssoOnly:
let state = "an SSO only server"
validateServerDescriptionIsHidden(for: state)
validateLoginFormIsHidden(for: state)
validateSSOButtonsAreShown(for: state)
case .fallback:
let state = "a fallback server"
validateFallback(for: state)
}
}
/// Checks that the server description label is shown.
func validateServerDescriptionIsVisible(for state: String) {
let descriptionLabel = app.staticTexts["serverDescriptionText"]
XCTAssertTrue(descriptionLabel.exists, "The server description should be shown for \(state).")
XCTAssertEqual(descriptionLabel.label, VectorL10n.authenticationServerInfoMatrixDescription, "The server description should be correct for \(state).")
}
/// Checks that the server description label is hidden.
func validateServerDescriptionIsHidden(for state: String) {
let descriptionLabel = app.staticTexts["serverDescriptionText"]
XCTAssertFalse(descriptionLabel.exists, "The server description should be shown for \(state).")
}
/// Checks that the username and password text fields are shown along with the next button.
func validateLoginFormIsVisible(for state: String) {
let usernameTextField = app.textFields.element
let passwordTextField = app.secureTextFields.element
let nextButton = app.buttons["nextButton"]
XCTAssertTrue(usernameTextField.exists, "Username input should be shown for \(state).")
XCTAssertTrue(passwordTextField.exists, "Password input should be shown for \(state).")
XCTAssertTrue(nextButton.exists, "The next button should be shown for \(state).")
}
/// Checks that the username and password text fields are hidden along with the next button.
func validateLoginFormIsHidden(for state: String) {
let usernameTextField = app.textFields.element
let passwordTextField = app.secureTextFields.element
let nextButton = app.buttons["nextButton"]
XCTAssertFalse(usernameTextField.exists, "Username input should not be shown for \(state).")
XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).")
XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).")
}
/// Checks that there is at least one SSO button shown on the screen.
func validateSSOButtonsAreShown(for state: String) {
let ssoButtons = app.buttons.matching(identifier: "ssoButton")
XCTAssertGreaterThan(ssoButtons.count, 0, "There should be at least 1 SSO button shown for \(state).")
}
/// Checks that no SSO buttons shown on the screen.
func validateSSOButtonsAreHidden(for state: String) {
let ssoButtons = app.buttons.matching(identifier: "ssoButton")
XCTAssertEqual(ssoButtons.count, 0, "There should not be any SSO buttons shown for \(state).")
}
/// Checks that the next button is shown but is disabled.
func validateNextButtonIsDisabled(for state: String) {
let nextButton = app.buttons["nextButton"]
XCTAssertTrue(nextButton.exists, "The next button should be shown.")
XCTAssertFalse(nextButton.isEnabled, "The next button should be disabled for \(state).")
}
/// Checks that the next button is shown and is enabled.
func validateNextButtonIsEnabled(for state: String) {
let nextButton = app.buttons["nextButton"]
XCTAssertTrue(nextButton.exists, "The next button should be shown.")
XCTAssertTrue(nextButton.isEnabled, "The next button should be enabled for \(state).")
}
func validateFallback(for state: String) {
let usernameTextField = app.textFields.element
let passwordTextField = app.secureTextFields.element
let nextButton = app.buttons["nextButton"]
let ssoButtons = app.buttons.matching(identifier: "ssoButton")
let fallbackButton = app.buttons["fallbackButton"]
XCTAssertFalse(usernameTextField.exists, "Username input should not be shown for \(state).")
XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).")
XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).")
XCTAssertEqual(ssoButtons.count, 0, "There should not be any SSO buttons shown for \(state).")
XCTAssertTrue(fallbackButton.exists, "The fallback button should be shown for \(state).")
XCTAssertTrue(fallbackButton.isEnabled, "The fallback button should be enabled for \(state).")
}
}

View File

@@ -0,0 +1,125 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import RiotSwiftUI
class AuthenticationLoginViewModelTests: XCTestCase {
let defaultHomeserver = AuthenticationHomeserverViewData.mockMatrixDotOrg
var viewModel: AuthenticationLoginViewModelProtocol!
var context: AuthenticationLoginViewModelType.Context!
@MainActor override func setUp() async throws {
viewModel = AuthenticationLoginViewModel(homeserver: defaultHomeserver)
context = viewModel.context
}
func testMatrixDotOrg() {
// Given the initial view model configured for matrix.org with some SSO providers.
let homeserver = defaultHomeserver
// Then the view state should contain a homeserver that matches matrix.org and shows SSO buttons.
XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should match the original.")
XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.")
}
@MainActor func testBasicServer() {
// Given a basic server example.com that only supports password registration.
let homeserver = AuthenticationHomeserverViewData.mockBasicServer
// When updating the view model with the server.
viewModel.update(homeserver: homeserver)
// Then the view state should be updated with the homeserver and hide the SSO buttons.
XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should should match the new homeserver.")
XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.")
}
func testUsernameWithEmptyPassword() {
// Given a form with an empty username and password.
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
// When entering a username without a password.
context.username = "bob"
context.password = ""
// Then the credentials should be considered invalid.
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
}
func testEmptyUsernameWithPassword() {
// Given a form with an empty username and password.
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
// When entering a password without a username.
context.username = ""
context.password = "12345678"
// Then the credentials should be considered invalid.
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
}
func testValidCredentials() {
// Given a form with an empty username and password.
XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.")
XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.")
XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.")
// When entering a username and an 8-character password.
context.username = "bob"
context.password = "12345678"
// Then the credentials should be considered valid.
XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid when the username and password are valid.")
}
@MainActor func testLoadingServer() {
// Given a form with valid credentials.
context.username = "bob"
context.password = "12345678"
XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid.")
XCTAssertFalse(context.viewState.isLoading, "The view shouldn't start in a loading state.")
// When updating the view model whilst loading a homeserver.
viewModel.update(isLoading: true)
// Then the view state should reflect that the homeserver is loading.
XCTAssertTrue(context.viewState.isLoading, "The view should now be in a loading state.")
// When updating the view model after loading a homeserver.
viewModel.update(isLoading: false)
// Then the view state should reflect that the homeserver is now loaded.
XCTAssertFalse(context.viewState.isLoading, "The view should be back in a loaded state.")
}
@MainActor func testFallbackServer() {
// Given a basic server example.com that only supports password registration.
let homeserver = AuthenticationHomeserverViewData.mockFallback
// When updating the view model with the server.
viewModel.update(homeserver: homeserver)
// Then the view state should be updated with the homeserver and hide the SSO buttons and login form.
XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.")
XCTAssertFalse(context.viewState.homeserver.showLoginForm, "The login form should not be shown.")
}
}

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
struct AuthenticationLoginScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
/// A boolean that can be toggled to give focus to the password text field.
/// This must be manually set back to `false` when the text field finishes editing.
@State private var isPasswordFocused = false
// MARK: Public
@ObservedObject var viewModel: AuthenticationLoginViewModel.Context
var body: some View {
ScrollView {
VStack(spacing: 0) {
header
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
.padding(.bottom, 36)
serverInfo
.padding(.leading, 12)
Rectangle()
.fill(theme.colors.quinaryContent)
.frame(height: 1)
.padding(.vertical, 21)
if viewModel.viewState.homeserver.showLoginForm {
loginForm
}
if viewModel.viewState.homeserver.showLoginForm && viewModel.viewState.showSSOButtons {
Text(VectorL10n.or)
.foregroundColor(theme.colors.secondaryContent)
.padding(.top, 16)
}
if viewModel.viewState.showSSOButtons {
ssoButtons
.padding(.top, 16)
}
if !viewModel.viewState.homeserver.showLoginForm && !viewModel.viewState.showSSOButtons {
fallbackButton
}
}
.readableFrame()
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
.background(theme.colors.background.ignoresSafeArea())
.alert(item: $viewModel.alertInfo) { $0.alert }
.accentColor(theme.colors.accent)
}
/// The header containing a Welcome Back title.
var header: some View {
Text(VectorL10n.authenticationLoginTitle)
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
}
/// The sever information section that includes a button to select a different server.
var serverInfo: some View {
AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address,
showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) {
viewModel.send(viewAction: .selectServer)
}
}
/// The form with text fields for username and password, along with a submit button.
var loginForm: some View {
VStack(spacing: 14) {
RoundedBorderTextField(placeHolder: VectorL10n.authenticationLoginUsername,
text: $viewModel.username,
isFirstResponder: false,
configuration: UIKitTextInputConfiguration(returnKeyType: .next,
autocapitalizationType: .none,
autocorrectionType: .no),
onEditingChanged: usernameEditingChanged,
onCommit: { isPasswordFocused = true })
.accessibilityIdentifier("usernameTextField")
Spacer().frame(height: 20)
RoundedBorderTextField(placeHolder: VectorL10n.authPasswordPlaceholder,
text: $viewModel.password,
isFirstResponder: isPasswordFocused,
configuration: UIKitTextInputConfiguration(returnKeyType: .done,
isSecureTextEntry: true),
onEditingChanged: passwordEditingChanged,
onCommit: submit)
.accessibilityIdentifier("passwordTextField")
Button { viewModel.send(viewAction: .forgotPassword) } label: {
Text(VectorL10n.authenticationLoginForgotPassword)
.font(theme.fonts.body)
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.bottom, 8)
Button(action: submit) {
Text(VectorL10n.next)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(!viewModel.viewState.hasValidCredentials || viewModel.viewState.isLoading)
.accessibilityIdentifier("nextButton")
}
}
/// A list of SSO buttons that can be used for login.
var ssoButtons: some View {
VStack(spacing: 16) {
ForEach(viewModel.viewState.homeserver.ssoIdentityProviders) { provider in
AuthenticationSSOButton(provider: provider) {
viewModel.send(viewAction: .continueWithSSO(provider))
}
.accessibilityIdentifier("ssoButton")
}
}
}
/// A fallback button that can be used for login.
var fallbackButton: some View {
Button(action: fallback) {
Text(VectorL10n.login)
}
.buttonStyle(PrimaryActionButtonStyle())
.accessibilityIdentifier("fallbackButton")
}
/// Parses the username for a homeserver.
func usernameEditingChanged(isEditing: Bool) {
guard !isEditing, !viewModel.username.isEmpty else { return }
viewModel.send(viewAction: .parseUsername)
}
/// Resets the password field focus.
func passwordEditingChanged(isEditing: Bool) {
guard !isEditing else { return }
isPasswordFocused = false
}
/// Sends the `next` view action so long as valid credentials have been input.
func submit() {
guard viewModel.viewState.hasValidCredentials else { return }
viewModel.send(viewAction: .next)
}
/// Sends the `fallback` view action.
func fallback() {
viewModel.send(viewAction: .fallback)
}
}
// MARK: - Previews
@available(iOS 15.0, *)
struct AuthenticationLogin_Previews: PreviewProvider {
static let stateRenderer = MockAuthenticationLoginScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
}
}

View File

@@ -25,17 +25,17 @@ enum AuthenticationRegistrationViewModelResult {
case validateUsername(String)
/// Create an account using the supplied credentials.
case createAccount(username: String, password: String)
/// Continue using the supplied SSO provider.
case continueWithSSO(SSOIdentityProvider)
/// Continue using a fallback
case fallback
}
// MARK: View
struct AuthenticationRegistrationViewState: BindableState {
/// The address of the homeserver.
var homeserverAddress: String
/// Whether or not to show the username and password text fields with the next button
var showRegistrationForm: Bool
/// An array containing the available SSO options for login.
var ssoIdentityProviders: [SSOIdentityProvider]
/// Data about the selected homeserver.
var homeserver: AuthenticationHomeserverViewData
/// View state that can be bound to from SwiftUI.
var bindings: AuthenticationRegistrationBindings
/// Whether or not the username field has been edited yet.
@@ -55,15 +55,9 @@ struct AuthenticationRegistrationViewState: BindableState {
usernameErrorMessage ?? VectorL10n.authenticationRegistrationUsernameFooter
}
/// A description that can be shown for the currently selected homeserver.
var serverDescription: String? {
guard homeserverAddress == "matrix.org" else { return nil }
return VectorL10n.authenticationRegistrationMatrixDescription
}
/// Whether to show any SSO buttons.
var showSSOButtons: Bool {
!ssoIdentityProviders.isEmpty
!homeserver.ssoIdentityProviders.isEmpty
}
/// Whether the current `username` is valid.
@@ -102,8 +96,10 @@ enum AuthenticationRegistrationViewAction {
case clearUsernameError
/// Continue using the input username and password.
case next
/// Login using the supplied SSO provider ID.
case continueWithSSO(id: String)
/// Continue using the supplied SSO provider.
case continueWithSSO(SSOIdentityProvider)
/// Continue using the fallback page
case fallback
}
enum AuthenticationRegistrationErrorType: Hashable {

View File

@@ -27,16 +27,13 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy
// MARK: Public
@MainActor var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)?
var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)?
// MARK: - Setup
init(homeserverAddress: String, showRegistrationForm: Bool = true, ssoIdentityProviders: [SSOIdentityProvider]) {
init(homeserver: AuthenticationHomeserverViewData) {
let bindings = AuthenticationRegistrationBindings()
let viewState = AuthenticationRegistrationViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress),
showRegistrationForm: showRegistrationForm,
ssoIdentityProviders: ssoIdentityProviders,
bindings: bindings)
let viewState = AuthenticationRegistrationViewState(homeserver: homeserver, bindings: bindings)
super.init(initialViewState: viewState)
}
@@ -55,15 +52,15 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy
Task { await clearUsernameError() }
case .next:
Task { await callback?(.createAccount(username: state.bindings.username, password: state.bindings.password)) }
case .continueWithSSO(let id):
break
case .continueWithSSO(let provider):
Task { await callback?(.continueWithSSO(provider)) }
case .fallback:
Task { await callback?(.fallback) }
}
}
@MainActor func update(homeserverAddress: String, showRegistrationForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) {
state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress)
state.showRegistrationForm = showRegistrationForm
state.ssoIdentityProviders = ssoIdentityProviders
@MainActor func update(homeserver: AuthenticationHomeserverViewData) {
state.homeserver = homeserver
}
@MainActor func displayError(_ type: AuthenticationRegistrationErrorType) {

View File

@@ -18,16 +18,14 @@ import Foundation
protocol AuthenticationRegistrationViewModelProtocol {
@MainActor var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? { get set }
var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? { get set }
var context: AuthenticationRegistrationViewModelType.Context { get }
/// Update the view with new homeserver information.
/// - 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])
/// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`.
@MainActor func update(homeserver: AuthenticationHomeserverViewData)
/// Display an error to the user.
/// - Parameter type: The type of error to be displayed.
@MainActor func displayError(_ type: AuthenticationRegistrationErrorType)
}

View File

@@ -28,8 +28,12 @@ struct AuthenticationRegistrationCoordinatorParameters {
}
enum AuthenticationRegistrationCoordinatorResult {
/// Continue using the supplied SSO provider.
case continueWithSSO(SSOIdentityProvider)
/// The screen completed with the associated registration result.
case completed(RegistrationResult)
case completed(result: RegistrationResult, password: String)
/// Continue using the fallback
case fallback
}
final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
@@ -53,26 +57,23 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
private var waitingIndicator: UserIndicator?
/// The authentication service used for the registration.
var authenticationService: AuthenticationService { parameters.authenticationService }
private var authenticationService: AuthenticationService { parameters.authenticationService }
/// The wizard used to handle the registration flow. May be `nil` when only SSO is supported.
var registrationWizard: RegistrationWizard?
private var registrationWizard: RegistrationWizard? { parameters.authenticationService.registrationWizard }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
@MainActor var callback: ((AuthenticationRegistrationCoordinatorResult) -> Void)?
var callback: (@MainActor (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 ?? [])
let viewModel = AuthenticationRegistrationViewModel(homeserver: homeserver.viewData)
authenticationRegistrationViewModel = viewModel
let view = AuthenticationRegistrationScreen(viewModel: viewModel.context)
@@ -100,6 +101,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
authenticationRegistrationViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationRegistrationCoordinator] AuthenticationRegistrationViewModel did complete with result: \(result).")
switch result {
case .selectServer:
self.presentServerSelectionScreen()
@@ -107,13 +109,17 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
self.validateUsername(username)
case .createAccount(let username, let password):
self.createAccount(username: username, password: password)
case .continueWithSSO(let provider):
self.callback?(.continueWithSSO(provider))
case .fallback:
self.callback?(.fallback)
}
}
}
/// Show a blocking activity indicator whilst saving.
@MainActor private func startLoading(label: String? = nil) {
waitingIndicator = indicatorPresenter.present(.loading(label: label ?? VectorL10n.loading, isInteractionBlocking: true))
@MainActor private func startLoading() {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
@@ -149,17 +155,16 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
return
}
// reAuthHelper.data = state.password
let deviceDisplayName = UIDevice.current.isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice
startLoading()
currentTask = Task { [weak self] in
do {
let result = try await registrationWizard.createAccount(username: username, password: password, initialDeviceDisplayName: deviceDisplayName)
let result = try await registrationWizard.createAccount(username: username,
password: password,
initialDeviceDisplayName: UIDevice.current.initialDisplayName)
guard !Task.isCancelled else { return }
callback?(.completed(result))
callback?(.completed(result: result, password: password))
self?.stopLoading()
} catch {
@@ -204,8 +209,9 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
/// Presents the server selection screen as a modal.
@MainActor private func presentServerSelectionScreen() {
MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen")
MXLog.debug("[AuthenticationRegistrationCoordinator] presentServerSelectionScreen")
let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService,
flow: .register,
hasModalPresentation: true)
let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] result in
@@ -227,11 +233,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) {
if result == .updated {
let homeserver = authenticationService.state.homeserver
authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
showRegistrationForm: homeserver.registrationFlow != nil,
ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? [])
self.registrationWizard = authenticationService.registrationWizard
authenticationRegistrationViewModel.update(homeserver: homeserver.viewData)
}
navigationRouter.dismissModule(animated: true) { [weak self] in

View File

@@ -28,6 +28,7 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable {
case passwordWithCredentials
case passwordWithUsernameError
case ssoOnly
case fallback
/// The associated screen
var screenType: Any.Type {
@@ -39,27 +40,21 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable {
let viewModel: AuthenticationRegistrationViewModel
switch self {
case .matrixDotOrg:
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://matrix.org", ssoIdentityProviders: [
SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil),
SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil),
SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil),
SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil),
SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil)
])
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockMatrixDotOrg)
case .passwordOnly:
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: [])
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer)
case .passwordWithCredentials:
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: [])
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer)
viewModel.context.username = "alice"
viewModel.context.password = "password"
case .passwordWithUsernameError:
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: [])
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer)
viewModel.state.hasEditedUsername = true
Task { await viewModel.displayError(.usernameUnavailable(VectorL10n.authInvalidUserName)) }
case .ssoOnly:
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://company.com",
showRegistrationForm: false,
ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)])
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockEnterpriseSSO)
case .fallback:
viewModel = AuthenticationRegistrationViewModel(homeserver: .mockFallback)
}

View File

@@ -34,12 +34,14 @@ class AuthenticationRegistrationUITests: MockScreenTest {
let state = "matrix.org"
validateRegistrationFormIsVisible(for: state)
validateSSOButtonsAreShown(for: state)
validateFallbackButtonIsHidden(for: state)
validateNoErrorsAreShown(for: state)
case .passwordOnly:
let state = "a password only server"
validateRegistrationFormIsVisible(for: state)
validateSSOButtonsAreHidden(for: state)
validateFallbackButtonIsHidden(for: state)
validateNextButtonIsDisabled(for: state)
@@ -48,6 +50,7 @@ class AuthenticationRegistrationUITests: MockScreenTest {
let state = "a password only server with credentials entered"
validateRegistrationFormIsVisible(for: state)
validateSSOButtonsAreHidden(for: state)
validateFallbackButtonIsHidden(for: state)
validateNextButtonIsEnabled(for: state)
@@ -56,6 +59,7 @@ class AuthenticationRegistrationUITests: MockScreenTest {
let state = "a password only server with an invalid username"
validateRegistrationFormIsVisible(for: state)
validateSSOButtonsAreHidden(for: state)
validateFallbackButtonIsHidden(for: state)
validateNextButtonIsDisabled(for: state)
@@ -64,6 +68,12 @@ class AuthenticationRegistrationUITests: MockScreenTest {
let state = "an SSO only server"
validateRegistrationFormIsHidden(for: state)
validateSSOButtonsAreShown(for: state)
validateFallbackButtonIsHidden(for: state)
case .fallback:
let state = "fallback"
validateRegistrationFormIsHidden(for: state)
validateSSOButtonsAreHidden(for: state)
validateFallbackButtonIsShown(for: state)
}
}
@@ -88,6 +98,21 @@ class AuthenticationRegistrationUITests: MockScreenTest {
XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).")
XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).")
}
/// Checks that the fallback button is hidden.
func validateFallbackButtonIsHidden(for state: String) {
let fallbackButton = app.buttons["fallbackButton"]
XCTAssertFalse(fallbackButton.exists, "The fallback button should not be shown for \(state).")
}
/// Checks that the fallback button is hidden.
func validateFallbackButtonIsShown(for state: String) {
let fallbackButton = app.buttons["fallbackButton"]
XCTAssertTrue(fallbackButton.exists, "The fallback button should be shown for \(state).")
XCTAssertTrue(fallbackButton.isEnabled, "The fallback button should be enabled for \(state).")
}
/// Checks that there is at least one SSO button shown on the screen.
func validateSSOButtonsAreShown(for state: String) {

View File

@@ -20,79 +20,49 @@ import Combine
@testable import RiotSwiftUI
@MainActor class AuthenticationRegistrationViewModelTests: XCTestCase {
let defaultHomeserver = AuthenticationHomeserverViewData.mockMatrixDotOrg
var viewModel: AuthenticationRegistrationViewModelProtocol!
var context: AuthenticationRegistrationViewModelType.Context!
@MainActor override func setUp() async throws {
viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "", ssoIdentityProviders: [])
viewModel = AuthenticationRegistrationViewModel(homeserver: defaultHomeserver)
context = viewModel.context
}
func testMatrixDotOrg() {
// Given matrix.org with some SSO providers.
let address = "https://matrix.org"
let ssoProviders = [
SSOIdentityProvider(id: "apple", name: "Apple", brand: "Apple", iconURL: nil),
SSOIdentityProvider(id: "google", name: "Google", brand: "Google", iconURL: nil),
SSOIdentityProvider(id: "github", name: "Github", brand: "Github", iconURL: nil)
]
// Given the initial view model configured for matrix.org with some SSO providers.
let homeserver = defaultHomeserver
// When updating the view model with the server.
viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: ssoProviders)
// Then the form should show the server description along with the username and password fields and the SSO buttons.
XCTAssertEqual(context.viewState.homeserverAddress, "matrix.org", "The homeserver address should have the https scheme stripped away.")
XCTAssertEqual(context.viewState.serverDescription, VectorL10n.authenticationRegistrationMatrixDescription, "A description should be shown for matrix.org.")
XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.")
// Then the view state should contain a homeserver that matches matrix.org and shows SSO buttons.
XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should match the original.")
XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.")
}
func testBasicServer() {
// Given a basic server example.com that only supports password registration.
let address = "https://example.com"
let homeserver = AuthenticationHomeserverViewData.mockBasicServer
// When updating the view model with the server.
viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: [])
viewModel.update(homeserver: homeserver)
// Then the form should only show the username and password section.
XCTAssertEqual(context.viewState.homeserverAddress, "example.com", "The homeserver address should have the https scheme stripped away.")
XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.")
XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.")
// Then the view state should be updated with the homeserver and hide the SSO buttons.
XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should should match the new homeserver.")
XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.")
}
func testFallbackServer() {
// Given a basic server example.com that only supports password registration.
let homeserver = AuthenticationHomeserverViewData.mockFallback
// When updating the view model with the server.
viewModel.update(homeserver: homeserver)
// Then the view state should be updated with the homeserver and hide the SSO buttons and registration form.
XCTAssertFalse(context.viewState.homeserver.showRegistrationForm, "The registration form should not be shown.")
XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.")
}
func 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 {
func testUsernameError() async throws {
// Given a form with a valid username.
context.username = "bob"
XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error when the view model is created.")
@@ -113,8 +83,7 @@ import Combine
context.send(viewAction: .clearUsernameError)
// Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors.
let task = Task { try await Task.sleep(nanoseconds: 100_000_000) }
_ = await task.result
try await Task.sleep(nanoseconds: 100_000_000)
// Then the error should be hidden again.
XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error anymore.")

View File

@@ -45,11 +45,11 @@ struct AuthenticationRegistrationScreen: View {
.frame(height: 1)
.padding(.vertical, 21)
if viewModel.viewState.showRegistrationForm {
if viewModel.viewState.homeserver.showRegistrationForm {
registrationForm
}
if viewModel.viewState.showRegistrationForm && viewModel.viewState.showSSOButtons {
if viewModel.viewState.homeserver.showRegistrationForm && viewModel.viewState.showSSOButtons {
Text(VectorL10n.or)
.foregroundColor(theme.colors.secondaryContent)
.padding(.top, 16)
@@ -59,6 +59,10 @@ struct AuthenticationRegistrationScreen: View {
ssoButtons
.padding(.top, 16)
}
if !viewModel.viewState.homeserver.showRegistrationForm && !viewModel.viewState.showSSOButtons {
fallbackButton
}
}
.readableFrame()
@@ -90,35 +94,9 @@ struct AuthenticationRegistrationScreen: View {
/// The sever information section that includes a button to select a different server.
var serverInfo: some View {
VStack(alignment: .leading, spacing: 4) {
Text(VectorL10n.authenticationRegistrationServerTitle)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.secondaryContent)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.viewState.homeserverAddress)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
if let serverDescription = viewModel.viewState.serverDescription {
Text(serverDescription)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.tertiaryContent)
.accessibilityIdentifier("serverDescriptionText")
}
}
Spacer()
Button { viewModel.send(viewAction: .selectServer) } label: {
Text(VectorL10n.edit)
.font(theme.fonts.body)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent))
}
}
AuthenticationServerInfoSection(address: viewModel.viewState.homeserver.address,
showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) {
viewModel.send(viewAction: .selectServer)
}
}
@@ -134,7 +112,8 @@ struct AuthenticationRegistrationScreen: View {
configuration: UIKitTextInputConfiguration(returnKeyType: .next,
autocapitalizationType: .none,
autocorrectionType: .no),
onEditingChanged: usernameEditingChanged)
onEditingChanged: usernameEditingChanged,
onCommit: { isPasswordFocused = true })
.onChange(of: viewModel.username) { _ in viewModel.send(viewAction: .clearUsernameError) }
.accessibilityIdentifier("usernameTextField")
@@ -146,7 +125,8 @@ struct AuthenticationRegistrationScreen: View {
isFirstResponder: isPasswordFocused,
configuration: UIKitTextInputConfiguration(returnKeyType: .done,
isSecureTextEntry: true),
onEditingChanged: passwordEditingChanged)
onEditingChanged: passwordEditingChanged,
onCommit: submit)
.accessibilityIdentifier("passwordTextField")
Button(action: submit) {
@@ -161,28 +141,35 @@ struct AuthenticationRegistrationScreen: View {
/// A list of SSO buttons that can be used for login.
var ssoButtons: some View {
VStack(spacing: 16) {
ForEach(viewModel.viewState.ssoIdentityProviders) { provider in
ForEach(viewModel.viewState.homeserver.ssoIdentityProviders) { provider in
AuthenticationSSOButton(provider: provider) {
viewModel.send(viewAction: .continueWithSSO(id: provider.id))
viewModel.send(viewAction: .continueWithSSO(provider))
}
.accessibilityIdentifier("ssoButton")
}
}
}
/// Validates the username when the text field ends editing, and selects the password text field.
func usernameEditingChanged(isEditing: Bool) {
guard !isEditing, !viewModel.username.isEmpty else { return }
viewModel.send(viewAction: .validateUsername)
isPasswordFocused = true
/// A fallback button that can be used for login.
var fallbackButton: some View {
Button(action: fallback) {
Text(VectorL10n.authRegister)
}
.buttonStyle(PrimaryActionButtonStyle())
.accessibilityIdentifier("fallbackButton")
}
/// Enables password validation the first time the user taps return, and sends the username and submits the form if possible.
/// Validates the username when the text field ends editing.
func usernameEditingChanged(isEditing: Bool) {
guard !isEditing, !viewModel.username.isEmpty else { return }
viewModel.send(viewAction: .validateUsername)
}
/// Enables password validation the first time the user finishes editing.
/// Additionally resets the password field focus.
func passwordEditingChanged(isEditing: Bool) {
guard !isEditing else { return }
isPasswordFocused = false
submit()
guard !viewModel.viewState.hasEditedPassword else { return }
viewModel.send(viewAction: .enablePasswordValidation)
@@ -193,6 +180,11 @@ struct AuthenticationRegistrationScreen: View {
guard viewModel.viewState.hasValidCredentials else { return }
viewModel.send(viewAction: .next)
}
/// Sends the `fallback` view action.
func fallback() {
viewModel.send(viewAction: .fallback)
}
}
// MARK: - Previews

View File

@@ -33,7 +33,7 @@ class AuthenticationServerSelectionViewModel: AuthenticationServerSelectionViewM
// MARK: - Setup
init(homeserverAddress: String, hasModalPresentation: Bool) {
let bindings = AuthenticationServerSelectionBindings(homeserverAddress: HomeserverAddress.displayable(homeserverAddress))
let bindings = AuthenticationServerSelectionBindings(homeserverAddress: homeserverAddress)
super.init(initialViewState: AuthenticationServerSelectionViewState(bindings: bindings,
hasModalPresentation: hasModalPresentation))
}

View File

@@ -19,6 +19,8 @@ import CommonKit
struct AuthenticationServerSelectionCoordinatorParameters {
let authenticationService: AuthenticationService
/// Whether the server selection is for the login flow or registration flow.
let flow: AuthenticationFlow
/// Whether the screen is presented modally or within a navigation stack.
let hasModalPresentation: Bool
}
@@ -56,7 +58,7 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable {
self.parameters = parameters
let homeserver = parameters.authenticationService.state.homeserver
let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.displayableAddress,
hasModalPresentation: parameters.hasModalPresentation)
let view = AuthenticationServerSelectionScreen(viewModel: viewModel.context)
authenticationServerSelectionViewModel = viewModel
@@ -111,14 +113,12 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable {
/// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible.
@MainActor private func useHomeserver(_ homeserverAddress: String) {
startLoading()
authenticationService.reset()
let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress)
Task {
do {
#warning("The screen should be configuration for .login too.")
try await authenticationService.startFlow(.register, for: homeserverAddress)
try await authenticationService.startFlow(parameters.flow, for: homeserverAddress)
stopLoading()
callback?(.updated)

View File

@@ -31,7 +31,7 @@ class AuthenticationServerSelectionViewModelTests: XCTestCase {
context = viewModel.context
}
@MainActor func testErrorMessage() async {
@MainActor func testErrorMessage() async throws {
// Given a new instance of the view model.
XCTAssertNil(context.viewState.footerErrorMessage, "There should not be an error message for a new view model.")
XCTAssertEqual(context.viewState.footerMessage, VectorL10n.authenticationServerSelectionServerFooter, "The standard footer message should be shown.")
@@ -48,8 +48,7 @@ class AuthenticationServerSelectionViewModelTests: XCTestCase {
context.send(viewAction: .clearFooterError)
// Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors.
let task = Task { try await Task.sleep(nanoseconds: 100_000_000) }
_ = await task.result
try await Task.sleep(nanoseconds: 100_000_000)
// Then the error message should now be removed.
XCTAssertNil(context.viewState.footerErrorMessage, "The error message should have been cleared.")

View File

@@ -59,7 +59,7 @@ final class AuthenticationTermsCoordinator: Coordinator, Presentable {
@MainActor init(parameters: AuthenticationTermsCoordinatorParameters) {
self.parameters = parameters
let subtitle = HomeserverAddress.displayable(parameters.homeserverAddress)
let subtitle = parameters.homeserverAddress
let policies = parameters.localizedPolicies.compactMap { AuthenticationTermsPolicy(url: $0.url, title: $0.name, subtitle: subtitle) }
let viewModel = AuthenticationTermsViewModel(policies: policies)

View File

@@ -21,6 +21,7 @@ import Foundation
enum MockAppScreens {
static let appScreens: [MockScreenState.Type] = [
MockLiveLocationSharingViewerScreenState.self,
MockAuthenticationLoginScreenState.self,
MockAuthenticationReCaptchaScreenState.self,
MockAuthenticationTermsScreenState.self,
MockAuthenticationVerifyEmailScreenState.self,

View File

@@ -18,15 +18,25 @@ import SwiftUI
@available(iOS 14.0, *)
extension ThemableTextField {
func showClearButton(text: Binding<String>, alignement: VerticalAlignment = .center) -> some View {
return modifier(ClearViewModifier(alignment: alignement, text: text))
/// Adds a clear button to the text field
/// - Parameters:
/// - show: A boolean that can be used to dynamically show/hide the button. Defaults to `true`.
/// - text: The text for the clear button to clear.
/// - alignment: The vertical alignment of the button in the text field. Default to `center`
@ViewBuilder
func showClearButton(_ show: Bool = true, text: Binding<String>, alignment: VerticalAlignment = .center) -> some View {
if show {
modifier(ClearViewModifier(alignment: alignment, text: text))
} else {
self
}
}
}
@available(iOS 14.0, *)
extension ThemableTextEditor {
func showClearButton(text: Binding<String>, alignement: VerticalAlignment = .top) -> some View {
return modifier(ClearViewModifier(alignment: alignement, text: text))
func showClearButton(text: Binding<String>, alignment: VerticalAlignment = .top) -> some View {
return modifier(ClearViewModifier(alignment: alignment, text: text))
}
}

View File

@@ -33,6 +33,7 @@ struct RoundedBorderTextField: View {
var onTextChanged: ((String) -> Void)? = nil
var onEditingChanged: ((Bool) -> Void)? = nil
var onCommit: (() -> Void)? = nil
// MARK: Private
@@ -52,6 +53,7 @@ struct RoundedBorderTextField: View {
.multilineTextAlignment(.leading)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0))
}
ZStack(alignment: .leading) {
if text.isEmpty {
Text(placeHolder)
@@ -60,32 +62,22 @@ struct RoundedBorderTextField: View {
.lineLimit(1)
.accessibilityHidden(true)
}
if isEnabled {
ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in
self.editing = edit
onEditingChanged?(edit)
})
.makeFirstResponder(isFirstResponder)
.showClearButton(text: $text)
.onChange(of: text) { newText in
onTextChanged?(newText)
}
.frame(height: 30)
.accessibilityLabel(text.isEmpty ? placeHolder : "")
} else {
ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in
self.editing = edit
onEditingChanged?(edit)
})
.makeFirstResponder(isFirstResponder)
.onChange(of: text) { newText in
onTextChanged?(newText)
}
.frame(height: 30)
.allowsHitTesting(false)
.opacity(0.5)
.accessibilityLabel(text.isEmpty ? placeHolder : "")
ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in
self.editing = edit
onEditingChanged?(edit)
}, onCommit: {
onCommit?()
})
.makeFirstResponder(isFirstResponder)
.showClearButton(isEnabled, text: $text)
.onChange(of: text) { newText in
onTextChanged?(newText)
}
.frame(height: 30)
.allowsHitTesting(isEnabled)
.opacity(isEnabled ? 1 : 0.5)
.accessibilityLabel(text.isEmpty ? placeHolder : "")
}
.padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: text.isEmpty ? 8 : 0))
.background(RoundedRectangle(cornerRadius: 8).fill(theme.colors.background))

View File

@@ -27,25 +27,24 @@ struct SecondaryActionButtonStyle: ButtonStyle {
configuration.label
.padding(12.0)
.frame(maxWidth: .infinity)
.foregroundColor(strokeColor(configuration.isPressed))
.foregroundColor(customColor ?? theme.colors.accent)
.font(theme.fonts.body)
.background(RoundedRectangle(cornerRadius: 8)
.strokeBorder()
.foregroundColor(strokeColor(configuration.isPressed)))
.opacity(isEnabled ? 1.0 : 0.6)
.foregroundColor(customColor ?? theme.colors.accent))
.opacity(opacity(when: configuration.isPressed))
}
func strokeColor(_ isPressed: Bool) -> Color {
if let customColor = customColor {
return customColor
}
return isPressed ? theme.colors.accent.opacity(0.6) : theme.colors.accent
private func opacity(when isPressed: Bool) -> CGFloat {
guard isEnabled else { return 0.6 }
return isPressed ? 0.6 : 1.0
}
}
@available(iOS 14.0, *)
struct SecondaryActionButtonStyle_Previews: PreviewProvider {
static var theme: ThemeSwiftUI = DefaultThemeSwiftUI()
static var previews: some View {
Group {
buttonGroup
@@ -64,14 +63,14 @@ struct SecondaryActionButtonStyle_Previews: PreviewProvider {
.buttonStyle(SecondaryActionButtonStyle())
.disabled(true)
Button { } label: {
Text("Clear BG")
.foregroundColor(.red)
}
.buttonStyle(SecondaryActionButtonStyle(customColor: .clear))
Button("Red BG") { }
.buttonStyle(SecondaryActionButtonStyle(customColor: .red))
Button { } label: {
Text("Custom")
.foregroundColor(theme.colors.secondaryContent)
}
.buttonStyle(SecondaryActionButtonStyle(customColor: theme.colors.quarterlyContent))
}
.padding()
}

View File

@@ -15,6 +15,7 @@
//
import SwiftUI
import CommonKit
protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable {
var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set }
@@ -29,6 +30,9 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
private let onboardingSplashScreenHostingController: VectorHostingController
private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
@@ -43,6 +47,8 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
onboardingSplashScreenViewModel = viewModel
onboardingSplashScreenHostingController = VectorHostingController(rootView: view)
onboardingSplashScreenHostingController.vc_removeBackTitle()
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingSplashScreenHostingController)
}
// MARK: - Public
@@ -52,13 +58,33 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
MXLog.debug("[OnboardingSplashScreenCoordinator] OnboardingSplashScreenViewModel did complete with result: \(result).")
guard let self = self else { return }
switch result {
case .login, .register:
case .login:
self.startLoading()
self.completion?(result)
case .register:
self.completion?(result)
}
}
}
func toPresentable() -> UIViewController {
return self.onboardingSplashScreenHostingController
return onboardingSplashScreenHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}

View File

@@ -57,7 +57,9 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
MXLog.debug("[OnboardingUseCaseSelectionCoordinator] OnboardingUseCaseViewModel did complete with result: \(result).")
// Show a loading indicator which can be dismissed externally by calling `stop`.
self.startLoading()
if result != .customServer {
self.startLoading()
}
self.completion?(result)
}
}

View File

@@ -39,6 +39,7 @@ enum LocationSharingViewAction {
case goToUserLocation
case startLiveSharing
case shareLiveLocation(timeout: LiveLocationSharingTimeout)
case userDidPan
}
enum LocationSharingViewModelResult {
@@ -70,9 +71,7 @@ struct LocationSharingViewState: BindableState {
var highlightedAnnotation: LocationAnnotation?
/// Indicates whether the user has moved around the map to drop a pin somewhere other than their current location
var isPinDropSharing: Bool {
return bindings.pinLocation != nil
}
var isPinDropSharing: Bool = false
var showLoadingIndicator: Bool = false

View File

@@ -78,12 +78,16 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie
completion?(.share(latitude: pinLocation.latitude, longitude: pinLocation.longitude, coordinateType: .pin))
case .goToUserLocation:
state.bindings.pinLocation = nil
state.showsUserLocation = true
state.isPinDropSharing = false
case .startLiveSharing:
self.startLiveLocationSharing()
case .shareLiveLocation(let timeout):
state.bindings.showingTimerSelector = false
completion?(.shareLiveLocation(timeout: timeout.rawValue))
case .userDidPan:
state.showsUserLocation = false
state.isPinDropSharing = true
}
}

View File

@@ -58,6 +58,9 @@ struct LocationSharingMapView: UIViewRepresentable {
/// Publish view errors if any
let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
/// Called when the user pan on the map
var userDidPan: (() -> Void)?
// MARK: - UIViewRepresentable
@@ -65,6 +68,9 @@ struct LocationSharingMapView: UIViewRepresentable {
let mapView = self.makeMapView()
mapView.delegate = context.coordinator
let panGesture = UIPanGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.didPan))
panGesture.delegate = context.coordinator
mapView.addGestureRecognizer(panGesture)
return mapView
}
@@ -77,7 +83,7 @@ struct LocationSharingMapView: UIViewRepresentable {
mapView.setCenter(highlightedAnnotation.coordinate, zoomLevel: Constants.mapZoomLevel, animated: false)
}
if self.showsUserLocation && mapCenterCoordinate == nil {
if self.showsUserLocation {
mapView.showsUserLocation = true
mapView.userTrackingMode = .follow
} else {
@@ -106,7 +112,7 @@ struct LocationSharingMapView: UIViewRepresentable {
@available(iOS 14, *)
extension LocationSharingMapView {
class Coordinator: NSObject, MGLMapViewDelegate {
class Coordinator: NSObject, MGLMapViewDelegate, UIGestureRecognizerDelegate {
// MARK: - Properties
@@ -126,7 +132,7 @@ extension LocationSharingMapView {
return LocationAnnotationView(userLocationAnnotation: userLocationAnnotation)
} else if let pinLocationAnnotation = annotation as? PinLocationAnnotation {
return LocationAnnotationView(pinLocationAnnotation: pinLocationAnnotation)
} else if annotation is MGLUserLocation && locationSharingMapView.mapCenterCoordinate == nil, let currentUserAvatarData = locationSharingMapView.userAvatarData {
} else if annotation is MGLUserLocation, let currentUserAvatarData = locationSharingMapView.userAvatarData {
// Replace default current location annotation view with a UserLocationAnnotatonView when the map is center on user location
return LocationAnnotationView(avatarData: currentUserAvatarData)
}
@@ -158,13 +164,7 @@ extension LocationSharingMapView {
}
func mapView(_ mapView: MGLMapView, regionDidChangeAnimated animated: Bool) {
let mapCenterCoordinate = mapView.centerCoordinate
// Prevent this function to set pinLocation when the map is openning
guard let userLocation = locationSharingMapView.userLocation,
!userLocation.isEqual(to: mapCenterCoordinate, precision: 0.0000000001) else {
return
}
locationSharingMapView.mapCenterCoordinate = mapCenterCoordinate
locationSharingMapView.mapCenterCoordinate = mapView.centerCoordinate
}
// MARK: Callout
@@ -182,11 +182,21 @@ extension LocationSharingMapView {
}
func mapView(_ mapView: MGLMapView, tapOnCalloutFor annotation: MGLAnnotation) {
locationSharingMapView.onCalloutTap?(annotation)
// Hide the callout
mapView.deselectAnnotation(annotation, animated: true)
}
// MARK: UIGestureRecognizer
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return gestureRecognizer is UIPanGestureRecognizer
}
@objc
func didPan() {
locationSharingMapView.userDidPan?()
}
}
}

View File

@@ -76,7 +76,10 @@ struct LocationSharingView: View {
showsUserLocation: context.viewState.showsUserLocation,
userLocation: $context.userLocation,
mapCenterCoordinate: $context.pinLocation,
errorSubject: context.viewState.errorSubject)
errorSubject: context.viewState.errorSubject,
userDidPan: {
context.send(viewAction: .userDidPan)
})
if context.viewState.isPinDropSharing {
LocationSharingMarkerView(backgroundColor: theme.colors.accent) {
Image(uiImage: Asset.Images.locationPinIcon.image)

View File

@@ -41,7 +41,9 @@ class HomeserverConfigurationTests: XCTestCase {
let expectedSecureBackupRequired = true
let secureBackupSetupMethods = ["passphrase"]
let expectedSecureBackupSetupMethods: [VectorWellKnownBackupSetupMethod] = [.passphrase]
let outboundKeysPreSharingMode = "on_room_opening"
let expectedOutboundKeysPreSharingMode: MXKKeyPreSharingStrategy = .whenEnteringRoom
let wellKnownDictionary: [String: Any] = [
"m.homeserver": [
"base_url": "https://your.homeserver.org"
@@ -61,7 +63,8 @@ class HomeserverConfigurationTests: XCTestCase {
"io.element.e2ee" : [
"default" : expectedE2EEEByDefaultEnabled,
"secure_backup_required": expectedSecureBackupRequired,
"secure_backup_setup_methods": secureBackupSetupMethods
"secure_backup_setup_methods": secureBackupSetupMethods,
"outbound_keys_pre_sharing_mode": outboundKeysPreSharingMode
],
"io.element.jitsi" : [
"preferredDomain" : expectedJitsiServer
@@ -78,6 +81,8 @@ class HomeserverConfigurationTests: XCTestCase {
XCTAssertEqual(homeserverConfiguration.encryption.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled)
XCTAssertEqual(homeserverConfiguration.encryption.isSecureBackupRequired, expectedSecureBackupRequired)
XCTAssertEqual(homeserverConfiguration.encryption.secureBackupSetupMethods, expectedSecureBackupSetupMethods)
XCTAssertEqual(homeserverConfiguration.encryption.outboundKeysPreSharingMode, expectedOutboundKeysPreSharingMode)
XCTAssertEqual(homeserverConfiguration.tileServer.mapStyleURL.absoluteString, expectedMapStyleURLString)
}
@@ -86,6 +91,7 @@ class HomeserverConfigurationTests: XCTestCase {
let expectedE2EEEByDefaultEnabled = true
let expectedSecureBackupRequired = false
let expectedSecureBackupSetupMethods: [VectorWellKnownBackupSetupMethod] = [.passphrase, .key]
let expectedOutboundKeysPreSharingMode: MXKKeyPreSharingStrategy = .whenTyping
let wellKnownDictionary: [String: Any] = [
"m.homeserver": [
@@ -104,5 +110,6 @@ class HomeserverConfigurationTests: XCTestCase {
XCTAssertEqual(homeserverConfiguration.encryption.isE2EEByDefaultEnabled, expectedE2EEEByDefaultEnabled)
XCTAssertEqual(homeserverConfiguration.encryption.isSecureBackupRequired, expectedSecureBackupRequired)
XCTAssertEqual(homeserverConfiguration.encryption.secureBackupSetupMethods, expectedSecureBackupSetupMethods)
XCTAssertEqual(homeserverConfiguration.encryption.outboundKeysPreSharingMode, expectedOutboundKeysPreSharingMode)
}
}

View File

@@ -81,4 +81,90 @@ class AuthenticationServiceTests: XCTestCase {
XCTAssertEqual(service.state.homeserver.addressFromUser, "https://matrix.org", "The new address entered by the user should be stored.")
XCTAssertEqual(service.state.homeserver.address, "https://matrix-client.matrix.org", "The new address discovered from the well-known should be stored.")
}
func testHomeserverViewDataForMatrixDotOrg() {
// Given a homeserver such as matrix.org.
let address = "https://matrix-client.matrix.org"
let addressFromUser = "https://matrix.org" // https is added when sanitising the input.
let ssoIdentityProviders = [
SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil),
SSOIdentityProvider(id: "2", name: "GitHub", brand: "github", iconURL: nil)
]
let flowResult = FlowResult(missingStages: [.email(isMandatory: true), .reCaptcha(isMandatory: true, siteKey: "1234")], completedStages: [])
let homeserver = AuthenticationState.Homeserver(address: address,
addressFromUser: addressFromUser,
preferredLoginMode: .ssoAndPassword(ssoIdentityProviders: ssoIdentityProviders),
registrationFlow: .flowResponse(flowResult))
// When creating view data for that homeserver.
let viewData = homeserver.viewData
// Then the view data should correctly represent the homeserver.
XCTAssertEqual(viewData.address, "matrix.org", "The displayed address should match the address supplied by the user, but without the scheme.")
XCTAssertEqual(viewData.isMatrixDotOrg, true, "The server should be detected as matrix.org.")
XCTAssertTrue(viewData.showLoginForm, "The login form should be shown.")
XCTAssertEqual(viewData.ssoIdentityProviders, ssoIdentityProviders, "The sso identity providers should match.")
XCTAssertTrue(viewData.showRegistrationForm, "The registration form should be shown.")
}
func testHomeserverViewDataForPasswordLoginOnly() {
// Given a homeserver with password login and registration disabled.
let address = "https://matrix.example.com"
let addressFromUser = "https://example.com" // https is added when sanitising the input.
let homeserver = AuthenticationState.Homeserver(address: address,
addressFromUser: addressFromUser,
preferredLoginMode: .password,
registrationFlow: nil)
// When creating view data for that homeserver.
let viewData = homeserver.viewData
// Then the view data should correctly represent the homeserver.
XCTAssertEqual(viewData.address, "example.com", "The displayed address should match the address supplied by the user, but without the scheme.")
XCTAssertEqual(viewData.isMatrixDotOrg, false, "The server should not be detected as matrix.org.")
XCTAssertTrue(viewData.showLoginForm, "The login form should be shown.")
XCTAssertEqual(viewData.ssoIdentityProviders, [], "There shouldn't be any sso identity providers.")
XCTAssertFalse(viewData.showRegistrationForm, "The registration form should not be shown.")
}
func testHomeserverViewDataForSSOOnly() {
// Given a homeserver that only supports authentication via SSO.
let address = "https://matrix.company.com"
let addressFromUser = "https://company.com" // https is added when sanitising the input.
let ssoIdentityProviders = [SSOIdentityProvider(id: "1", name: "SAML", brand: nil, iconURL: nil)]
let homeserver = AuthenticationState.Homeserver(address: address,
addressFromUser: addressFromUser,
preferredLoginMode: .sso(ssoIdentityProviders: ssoIdentityProviders),
registrationFlow: nil)
// When creating view data for that homeserver.
let viewData = homeserver.viewData
// Then the view data should correctly represent the homeserver.
XCTAssertEqual(viewData.address, "company.com", "The displayed address should match the address supplied by the user, but without the scheme.")
XCTAssertEqual(viewData.isMatrixDotOrg, false, "The server should not be detected as matrix.org.")
XCTAssertFalse(viewData.showLoginForm, "The login form should not be shown.")
XCTAssertEqual(viewData.ssoIdentityProviders, ssoIdentityProviders, "The sso identity providers should match.")
XCTAssertFalse(viewData.showRegistrationForm, "The registration form should not be shown.")
}
func testHomeserverViewDataForLocalHomeserver() {
// Given a local homeserver that supports login and registration but only via a password.
let addressFromUser = "http://localhost:8008" // https is added when sanitising the input.
let flowResult = FlowResult(missingStages: [.dummy(isMandatory: true)], completedStages: [])
let homeserver = AuthenticationState.Homeserver(address: addressFromUser,
addressFromUser: addressFromUser,
preferredLoginMode: .password,
registrationFlow: .flowResponse(flowResult))
// When creating view data for that homeserver.
let viewData = homeserver.viewData
// Then the view data should correctly represent the homeserver.
XCTAssertEqual(viewData.address, "http://localhost:8008", "The displayed address should match address supplied by the user, complete with the scheme.")
XCTAssertEqual(viewData.isMatrixDotOrg, false, "The server should not be detected as matrix.org.")
XCTAssertTrue(viewData.showLoginForm, "The login form should be shown.")
XCTAssertEqual(viewData.ssoIdentityProviders, [], "There shouldn't be any sso identity providers.")
XCTAssertTrue(viewData.showRegistrationForm, "The registration form should be shown.")
}
}