diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 46a6fab45..e11eca47a 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -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. diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index fa3af8d1d..baba82853 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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. diff --git a/.github/workflows/ci-ui-tests.yml b/.github/workflows/ci-ui-tests.yml index fb65d471d..28d7a2894 100644 --- a/.github/workflows/ci-ui-tests.yml +++ b/.github/workflows/ci-ui-tests.yml @@ -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. diff --git a/.github/workflows/release-alpha.yml b/.github/workflows/release-alpha.yml index d8222469f..889f57a75 100644 --- a/.github/workflows/release-alpha.yml +++ b/.github/workflows/release-alpha.yml @@ -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. diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml index 994a13941..e5799dc61 100644 --- a/.github/workflows/triage-move-labelled.yml +++ b/.github/workflows/triage-move-labelled.yml @@ -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: diff --git a/.github/workflows/triage-priority-bugs.yml b/.github/workflows/triage-priority-bugs.yml index 843c6234c..5226d16e8 100644 --- a/.github/workflows/triage-priority-bugs.yml +++ b/.github/workflows/triage-priority-bugs.yml @@ -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: diff --git a/CHANGES.md b/CHANGES.md index 9056b686c..aa5b4a0b8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 0593af953..9636ce332 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -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 diff --git a/Podfile b/Podfile index 81449b253..040fc0b69 100644 --- a/Podfile +++ b/Podfile @@ -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' diff --git a/Podfile.lock b/Podfile.lock index 17f4e117b..a6e20f718 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -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 diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index a6190a88b..a85d5223a 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -23,13 +23,17 @@ // MARK: Onboarding Authentication WIP "authentication_registration_title" = "Create your account"; "authentication_registration_message" = "We’ll need some info to get you set up."; -"authentication_registration_server_title" = "Choose your server to store your data"; -"authentication_registration_matrix_description" = "Join millions for free on the largest public server"; "authentication_registration_username" = "Username"; -"authentication_registration_password" = "Password"; "authentication_registration_username_footer" = "You can’t change this later"; "authentication_registration_password_footer" = "Must be 8 characters or more"; +"authentication_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"; diff --git a/RiotSwiftUI/Modules/Authentication/Common/HomeserverAddress.swift b/Riot/Categories/MXPreSharingStrategy.swift similarity index 51% rename from RiotSwiftUI/Modules/Authentication/Common/HomeserverAddress.swift rename to Riot/Categories/MXPreSharingStrategy.swift index d0bca43e0..1503234e0 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/HomeserverAddress.swift +++ b/Riot/Categories/MXPreSharingStrategy.swift @@ -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 + } } } diff --git a/Riot/Categories/UIDevice.swift b/Riot/Categories/UIDevice.swift index 093a8bdee..c3405e8ed 100644 --- a/Riot/Categories/UIDevice.swift +++ b/Riot/Categories/UIDevice.swift @@ -34,4 +34,8 @@ import UIKit return userInterfaceIdiom == .phone } + var initialDisplayName: String { + isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice + } + } diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index b8040fb9e..cdad0d276 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -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") - } /// We’ll need some info to get you set up. static var authenticationRegistrationMessage: String { return VectorL10n.tr("Untranslated", "authentication_registration_message") } - /// Password - static var authenticationRegistrationPassword: String { - return VectorL10n.tr("Untranslated", "authentication_registration_password") - } /// Must be 8 characters or more static var authenticationRegistrationPasswordFooter: String { return VectorL10n.tr("Untranslated", "authentication_registration_password_footer") } - /// Choose your server to store your data - static var authenticationRegistrationServerTitle: String { - return VectorL10n.tr("Untranslated", "authentication_registration_server_title") - } /// Create your account static var authenticationRegistrationTitle: String { return VectorL10n.tr("Untranslated", "authentication_registration_title") @@ -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") diff --git a/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift b/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift index 54bf0dcf6..5dcb042bc 100644 --- a/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift +++ b/Riot/Model/HomeserverConfiguration/HomeserverConfigurationBuilder.swift @@ -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? diff --git a/Riot/Model/HomeserverConfiguration/HomeserverEncryptionConfiguration.swift b/Riot/Model/HomeserverConfiguration/HomeserverEncryptionConfiguration.swift index 19b9aaee1..6672bff45 100644 --- a/Riot/Model/HomeserverConfiguration/HomeserverEncryptionConfiguration.swift +++ b/Riot/Model/HomeserverConfiguration/HomeserverEncryptionConfiguration.swift @@ -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() diff --git a/Riot/Model/WellKnown/VectorWellKnown.swift b/Riot/Model/WellKnown/VectorWellKnown.swift index 8d5669b21..71c127e89 100644 --- a/Riot/Model/WellKnown/VectorWellKnown.swift +++ b/Riot/Model/WellKnown/VectorWellKnown.swift @@ -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) } } diff --git a/Riot/Modules/Application/AppCoordinator.swift b/Riot/Modules/Application/AppCoordinator.swift index af141ad72..cd9591d0b 100755 --- a/Riot/Modules/Application/AppCoordinator.swift +++ b/Riot/Modules/Application/AppCoordinator.swift @@ -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 diff --git a/Riot/Modules/Application/LegacyAppDelegate.h b/Riot/Modules/Application/LegacyAppDelegate.h index 6875c7d0d..459776cf3 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.h +++ b/Riot/Modules/Application/LegacyAppDelegate.h @@ -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 diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index d51df3891..679da5248 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -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 diff --git a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift index 13a33b2d0..4265c3162 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift @@ -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() diff --git a/Riot/Modules/Authentication/AuthenticationViewController.m b/Riot/Modules/Authentication/AuthenticationViewController.m index 1f26b020a..93f8362ff 100644 --- a/Riot/Modules/Authentication/AuthenticationViewController.m +++ b/Riot/Modules/Authentication/AuthenticationViewController.m @@ -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 diff --git a/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift b/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift index 84957a7a1..ec614b8b9 100644 --- a/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift @@ -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, diff --git a/Riot/Modules/Authentication/SSO/SSOAuthenticationPresenter.swift b/Riot/Modules/Authentication/SSO/SSOAuthenticationPresenter.swift index 2f7c0e1a0..1145d8651 100644 --- a/Riot/Modules/Authentication/SSO/SSOAuthenticationPresenter.swift +++ b/Riot/Modules/Authentication/SSO/SSOAuthenticationPresenter.swift @@ -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) } } diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index c3c7057ba..fd9319399 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -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() + } + } diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 37882bec5..3521d7ee2 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -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 } diff --git a/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift b/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift index 2286bc046..8358cd8c1 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift @@ -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 diff --git a/Riot/Modules/Onboarding/OnboardingCoordinatorProtocol.swift b/Riot/Modules/Onboarding/OnboardingCoordinatorProtocol.swift index 83954667a..597347bb0 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinatorProtocol.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinatorProtocol.swift @@ -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 } diff --git a/Riot/Modules/Room/MXKRoomViewController.m b/Riot/Modules/Room/MXKRoomViewController.m index a8d79f084..db003818e 100644 --- a/Riot/Modules/Room/MXKRoomViewController.m +++ b/Riot/Modules/Room/MXKRoomViewController.m @@ -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(); + } }]; } diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift new file mode 100644 index 000000000..4b56563c3 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift @@ -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: []) + } + +} diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift index 41773e733..831783517 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift @@ -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}`. diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift similarity index 50% rename from RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift rename to RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift index ba7e8b099..767ed7d18 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift @@ -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) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift new file mode 100644 index 000000000..7401d2259 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift @@ -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)) + } + } + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 90cd992f4..060dca366 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -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 { diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift index 0ce762aac..ccc4f4d97 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift @@ -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 + } + } } } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift index 33ed39482..eef20e50d 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift new file mode 100644 index 000000000..049bfb65e --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -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? +} + +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 +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift new file mode 100644 index 000000000..aa73f0586 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -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 + +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) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift new file mode 100644 index 000000000..afe9d3a92 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift @@ -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) +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift new file mode 100644 index 000000000..8f354b957 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -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? { + 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) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift new file mode 100644 index 000000000..1e242c7e1 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift @@ -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)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift new file mode 100644 index 000000000..3bfda9406 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift @@ -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).") + } + +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift new file mode 100644 index 000000000..013eb0ec7 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift @@ -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.") + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift new file mode 100644 index 000000000..40550e50e --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -0,0 +1,190 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +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) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift index da72ac4eb..429554e4f 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift @@ -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 { diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift index 5b12823f3..73ec90f16 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift @@ -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) { diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift index a10a9a4d7..e292edf8e 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift @@ -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) } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index 12b1ac037..1e040b66a 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift index 7f5721fa9..2c0c280ef 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift @@ -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) } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift index c21206728..3725aa0ab 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift @@ -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) { diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift index 0d78b9b1e..d6565b2f8 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift @@ -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.") diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift index bd645f003..da80554bf 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift index 4484fbcad..ca68ce068 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift @@ -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)) } diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift index 39efc554c..3ad2b4866 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift @@ -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) diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift index 46f4aac08..bdab021d3 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift @@ -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.") diff --git a/RiotSwiftUI/Modules/Authentication/Terms/Coordinator/AuthenticationTermsCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Terms/Coordinator/AuthenticationTermsCoordinator.swift index 252406a0e..04a3bac6f 100644 --- a/RiotSwiftUI/Modules/Authentication/Terms/Coordinator/AuthenticationTermsCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Terms/Coordinator/AuthenticationTermsCoordinator.swift @@ -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) diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 0460ee893..51dcd92fa 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -21,6 +21,7 @@ import Foundation enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ MockLiveLocationSharingViewerScreenState.self, + MockAuthenticationLoginScreenState.self, MockAuthenticationReCaptchaScreenState.self, MockAuthenticationTermsScreenState.self, MockAuthenticationVerifyEmailScreenState.self, diff --git a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift index ca404d749..bce16b4ff 100644 --- a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift +++ b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift @@ -18,15 +18,25 @@ import SwiftUI @available(iOS 14.0, *) extension ThemableTextField { - func showClearButton(text: Binding, 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, 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, alignement: VerticalAlignment = .top) -> some View { - return modifier(ClearViewModifier(alignment: alignement, text: text)) + func showClearButton(text: Binding, alignment: VerticalAlignment = .top) -> some View { + return modifier(ClearViewModifier(alignment: alignment, text: text)) } } diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift index c9f3d73c3..f3ea7c89f 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -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)) diff --git a/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift b/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift index b6051b39b..2d081dd2d 100644 --- a/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift +++ b/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift @@ -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() } diff --git a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift index e269e55e2..534bb4e64 100644 --- a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift @@ -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 } } diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift index d3ef2a79f..7c7382958 100644 --- a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift @@ -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) } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift index b4ade5e31..ec9eb58bd 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift index 78a38040b..e2b64ff85 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift @@ -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 } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift index 97836ef64..8ad3ccceb 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift @@ -58,6 +58,9 @@ struct LocationSharingMapView: UIViewRepresentable { /// Publish view errors if any let errorSubject: PassthroughSubject + + /// 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?() + } } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift index 52ec07f22..c986f5a6a 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift @@ -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) diff --git a/RiotTests/HomeserverConfigurationTests.swift b/RiotTests/HomeserverConfigurationTests.swift index ec8c8c161..24cc50cd7 100644 --- a/RiotTests/HomeserverConfigurationTests.swift +++ b/RiotTests/HomeserverConfigurationTests.swift @@ -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) } } diff --git a/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift b/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift index 11304eb23..6dfe7dfbc 100644 --- a/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift +++ b/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift @@ -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.") + } }