Add support for Account Provisioning links. (#4108)

* Add support for account provisioning links and route them to the authentication flow.

* Use the provisioning parameters to configure the authentication flow.

* Add UI tests for the provisioned authentication flow.

* Record new preview snapshots.

* Add unit tests.

* Make the domain configurable in the app settings.

* Use the loginHint in the login screen too.
This commit is contained in:
Doug
2025-05-12 13:28:34 +01:00
committed by GitHub
parent e795d07477
commit 876618d557
40 changed files with 698 additions and 159 deletions
+5 -5
View File
@@ -608,6 +608,7 @@
7691233E3572A9173FD96CB3 /* SecureBackupKeyBackupScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E88534A39781D76487D59DF /* SecureBackupKeyBackupScreenViewModelTests.swift */; }; 7691233E3572A9173FD96CB3 /* SecureBackupKeyBackupScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E88534A39781D76487D59DF /* SecureBackupKeyBackupScreenViewModelTests.swift */; };
76C874243A8C440D6CF7B344 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */; }; 76C874243A8C440D6CF7B344 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */; };
7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF1EE0AA78470C674554262 /* PillTextAttachment.swift */; }; 7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF1EE0AA78470C674554262 /* PillTextAttachment.swift */; };
7756C4E90CABE6F14F7920A0 /* BugReportUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */; };
77574A519A4E484880053EAD /* IdentityConfirmationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FDF541AE914059942B575B4 /* IdentityConfirmationScreenModels.swift */; }; 77574A519A4E484880053EAD /* IdentityConfirmationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FDF541AE914059942B575B4 /* IdentityConfirmationScreenModels.swift */; };
77693820498ABF3508814D49 /* AppLockServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97F9661ABF08CE002054A2 /* AppLockServiceTests.swift */; }; 77693820498ABF3508814D49 /* AppLockServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97F9661ABF08CE002054A2 /* AppLockServiceTests.swift */; };
77920AFA8091AC6B9F190C90 /* Signposter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752A0EB49BF5BCEA37EDF7A3 /* Signposter.swift */; }; 77920AFA8091AC6B9F190C90 /* Signposter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752A0EB49BF5BCEA37EDF7A3 /* Signposter.swift */; };
@@ -819,7 +820,6 @@
9E838A62918E47BC72D6640D /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB54B4F94686CCF0289B72F /* UserIndicatorPresenter.swift */; }; 9E838A62918E47BC72D6640D /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB54B4F94686CCF0289B72F /* UserIndicatorPresenter.swift */; };
9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */; }; 9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */; };
9EE71509E6E7519A2B2388B3 /* KnockRequestsListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD9C9A31D9AB3B6D8128E69 /* KnockRequestsListScreenModels.swift */; }; 9EE71509E6E7519A2B2388B3 /* KnockRequestsListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD9C9A31D9AB3B6D8128E69 /* KnockRequestsListScreenModels.swift */; };
9EF72884B74BC39B01640098 /* BugReportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DF257F5D968E5DD719583C /* BugReportTests.swift */; };
9F11B9F347F9E2D236799FB3 /* ElementCallServiceConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */; }; 9F11B9F347F9E2D236799FB3 /* ElementCallServiceConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */; };
9F11E743EA01482E78A438B0 /* GlobalSearchScreenCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DB19219E6CC4D002E15D48 /* GlobalSearchScreenCell.swift */; }; 9F11E743EA01482E78A438B0 /* GlobalSearchScreenCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DB19219E6CC4D002E15D48 /* GlobalSearchScreenCell.swift */; };
9FB41B0E8B2AA9B404E52C8B /* AppLockSetupBiometricsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */; }; 9FB41B0E8B2AA9B404E52C8B /* AppLockSetupBiometricsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */; };
@@ -1577,7 +1577,6 @@
27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = "<group>"; }; 27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = "<group>"; };
27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = "<group>"; }; 27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = "<group>"; };
27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenModels.swift; sourceTree = "<group>"; }; 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenModels.swift; sourceTree = "<group>"; };
27DF257F5D968E5DD719583C /* BugReportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportTests.swift; sourceTree = "<group>"; };
28146817C61423CACCF942F5 /* CallScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenModels.swift; sourceTree = "<group>"; }; 28146817C61423CACCF942F5 /* CallScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenModels.swift; sourceTree = "<group>"; };
283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCacheTests.swift; sourceTree = "<group>"; }; 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCacheTests.swift; sourceTree = "<group>"; };
284FEEB0789B8894E52A7F34 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; }; 284FEEB0789B8894E52A7F34 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
@@ -2343,6 +2342,7 @@
C618CA2B6C8758B06C88013C /* CreateRoomCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomCoordinator.swift; sourceTree = "<group>"; }; C618CA2B6C8758B06C88013C /* CreateRoomCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomCoordinator.swift; sourceTree = "<group>"; };
C687844F60BFF532D49A994C /* AnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTests.swift; sourceTree = "<group>"; }; C687844F60BFF532D49A994C /* AnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTests.swift; sourceTree = "<group>"; };
C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineItem.swift; sourceTree = "<group>"; }; C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineItem.swift; sourceTree = "<group>"; };
C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportUITests.swift; sourceTree = "<group>"; };
C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderAvatarImage.swift; sourceTree = "<group>"; }; C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderAvatarImage.swift; sourceTree = "<group>"; };
C715CFE00686DACA59D836EA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/SAS.strings; sourceTree = "<group>"; }; C715CFE00686DACA59D836EA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/SAS.strings; sourceTree = "<group>"; };
C729D95CB4588D4D9AAC3DFA /* RoomChangePermissionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenModels.swift; sourceTree = "<group>"; }; C729D95CB4588D4D9AAC3DFA /* RoomChangePermissionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenModels.swift; sourceTree = "<group>"; };
@@ -4919,7 +4919,7 @@
19DD166C3625EE426203FA29 /* AppLockSetupTests.swift */, 19DD166C3625EE426203FA29 /* AppLockSetupTests.swift */,
E8495F37D6245AD0CFA1F60B /* AppLockTests.swift */, E8495F37D6245AD0CFA1F60B /* AppLockTests.swift */,
37F46CC4FD89ECF4CF26391A /* AuthenticationFlowCoordinatorTests.swift */, 37F46CC4FD89ECF4CF26391A /* AuthenticationFlowCoordinatorTests.swift */,
27DF257F5D968E5DD719583C /* BugReportTests.swift */, C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */,
2214C32EA81FA9168D923D4C /* CreateRoomScreenTests.swift */, 2214C32EA81FA9168D923D4C /* CreateRoomScreenTests.swift */,
89BB11A792EF6F70B95B467E /* EncryptionResetTests.swift */, 89BB11A792EF6F70B95B467E /* EncryptionResetTests.swift */,
57AD14D3ADADE8F6A10F9E88 /* EncryptionSettingsTests.swift */, 57AD14D3ADADE8F6A10F9E88 /* EncryptionSettingsTests.swift */,
@@ -7836,7 +7836,7 @@
05E797C4E0048BB487E5C4D6 /* AppLockTests.swift in Sources */, 05E797C4E0048BB487E5C4D6 /* AppLockTests.swift in Sources */,
7405B4824D45BA7C3D943E76 /* Application.swift in Sources */, 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */,
CB07184D37D5D65327A5A693 /* AuthenticationFlowCoordinatorTests.swift in Sources */, CB07184D37D5D65327A5A693 /* AuthenticationFlowCoordinatorTests.swift in Sources */,
9EF72884B74BC39B01640098 /* BugReportTests.swift in Sources */, 7756C4E90CABE6F14F7920A0 /* BugReportUITests.swift in Sources */,
94D0F36A87E596A93C0C178A /* Bundle.swift in Sources */, 94D0F36A87E596A93C0C178A /* Bundle.swift in Sources */,
28AB1614E749D1147A2AC6C2 /* CreateRoomScreenTests.swift in Sources */, 28AB1614E749D1147A2AC6C2 /* CreateRoomScreenTests.swift in Sources */,
8D24671992A1C1753B211221 /* EncryptionResetTests.swift in Sources */, 8D24671992A1C1753B211221 /* EncryptionResetTests.swift in Sources */,
@@ -8657,7 +8657,7 @@
repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift"; repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift";
requirement = { requirement = {
kind = exactVersion; kind = exactVersion;
version = 25.05.07; version = 25.05.09;
}; };
}; };
701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = { 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = {
@@ -158,8 +158,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/element-hq/matrix-rust-components-swift", "location" : "https://github.com/element-hq/matrix-rust-components-swift",
"state" : { "state" : {
"revision" : "143df312f4dd3902d6013aba110d4d9f4093335d", "revision" : "cf913e1417825d08a67cd517b9272b7b808193a8",
"version" : "25.5.7" "version" : "25.5.9"
} }
}, },
{ {
@@ -55,7 +55,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
private let appRouteURLParser: AppRouteURLParser private let appRouteURLParser: AppRouteURLParser
@Consumable private var storedAppRoute: AppRoute? private var storedAppRoute: AppRoute?
@Consumable private var storedInlineReply: (roomID: String, message: String)? @Consumable private var storedInlineReply: (roomID: String, message: String)?
@Consumable private var storedRoomsToAwait: Set<String>? @Consumable private var storedRoomsToAwait: Set<String>?
@@ -219,6 +219,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
if let route = appRouteURLParser.route(from: url) { if let route = appRouteURLParser.route(from: url) {
switch route { switch route {
case .accountProvisioningLink:
handleAppRoute(route)
case .genericCallLink(let url): case .genericCallLink(let url):
if let userSessionFlowCoordinator { if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(route, animated: true) userSessionFlowCoordinator.handleAppRoute(route, animated: true)
@@ -508,17 +510,23 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
appSettings: appSettings, appSettings: appSettings,
appHooks: appHooks) appHooks: appHooks)
authenticationFlowCoordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService, let coordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
qrCodeLoginService: qrCodeLoginService, qrCodeLoginService: qrCodeLoginService,
bugReportService: ServiceLocator.shared.bugReportService, bugReportService: ServiceLocator.shared.bugReportService,
navigationRootCoordinator: navigationRootCoordinator, navigationRootCoordinator: navigationRootCoordinator,
appMediator: appMediator, appMediator: appMediator,
appSettings: appSettings, appSettings: appSettings,
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController) userIndicatorController: ServiceLocator.shared.userIndicatorController)
authenticationFlowCoordinator?.delegate = self coordinator.delegate = self
authenticationFlowCoordinator?.start() authenticationFlowCoordinator = coordinator
coordinator.start()
if storedAppRoute?.isAuthenticationRoute == true,
let storedAppRoute = storedAppRoute.take() {
coordinator.handleAppRoute(storedAppRoute, animated: false)
}
} }
private func runPostSessionSetupTasks() async { private func runPostSessionSetupTasks() async {
@@ -530,7 +538,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
userSession.clientProxy.roomsToAwait = storedRoomsToAwait userSession.clientProxy.roomsToAwait = storedRoomsToAwait
} }
if let storedAppRoute { if storedAppRoute?.isAuthenticationRoute == false,
let storedAppRoute = storedAppRoute.take() {
userSessionFlowCoordinator.handleAppRoute(storedAppRoute, animated: false) userSessionFlowCoordinator.handleAppRoute(storedAppRoute, animated: false)
} }
@@ -796,9 +805,22 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
} }
private func handleAppRoute(_ appRoute: AppRoute) { private func handleAppRoute(_ appRoute: AppRoute) {
if let userSessionFlowCoordinator { var handled = false
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
} else { switch appRoute {
case .accountProvisioningLink:
if let authenticationFlowCoordinator {
authenticationFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
handled = true
}
default:
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
handled = true
}
}
if !handled {
storedAppRoute = appRoute storedAppRoute = appRoute
} }
} }
@@ -105,6 +105,7 @@ final class AppSettings {
chatBackupDetailsURL: URL, chatBackupDetailsURL: URL,
identityPinningViolationDetailsURL: URL, identityPinningViolationDetailsURL: URL,
elementWebHosts: [String], elementWebHosts: [String],
accountProvisioningHost: String,
bugReportApplicationID: String, bugReportApplicationID: String,
analyticsTermsURL: URL?, analyticsTermsURL: URL?,
mapTilerConfiguration: MapTilerConfiguration) { mapTilerConfiguration: MapTilerConfiguration) {
@@ -121,6 +122,7 @@ final class AppSettings {
self.chatBackupDetailsURL = chatBackupDetailsURL self.chatBackupDetailsURL = chatBackupDetailsURL
self.identityPinningViolationDetailsURL = identityPinningViolationDetailsURL self.identityPinningViolationDetailsURL = identityPinningViolationDetailsURL
self.elementWebHosts = elementWebHosts self.elementWebHosts = elementWebHosts
self.accountProvisioningHost = accountProvisioningHost
self.bugReportApplicationID = bugReportApplicationID self.bugReportApplicationID = bugReportApplicationID
self.analyticsTermsURL = analyticsTermsURL self.analyticsTermsURL = analyticsTermsURL
self.mapTilerConfiguration = mapTilerConfiguration self.mapTilerConfiguration = mapTilerConfiguration
@@ -176,6 +178,8 @@ final class AppSettings {
private(set) var identityPinningViolationDetailsURL: URL = "https://element.io/help#encryption18" private(set) var identityPinningViolationDetailsURL: URL = "https://element.io/help#encryption18"
/// Any domains that Element web may be hosted on - used for handling links. /// Any domains that Element web may be hosted on - used for handling links.
private(set) var elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"] private(set) var elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"]
/// The domain that account provisioning links will be hosted on - used for handling the links.
private(set) var accountProvisioningHost = "mobile.element.io"
@UserPreference(key: UserDefaultsKeys.appAppearance, defaultValue: .system, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.appAppearance, defaultValue: .system, storageType: .userDefaults(store))
var appAppearance: AppAppearance var appAppearance: AppAppearance
@@ -8,7 +8,12 @@
import Foundation import Foundation
import MatrixRustSDK import MatrixRustSDK
enum AppRoute: Equatable, Hashable { // MARK: - Routes
enum AppRoute: Hashable {
/// An account provisioning link generated externally.
case accountProvisioningLink(AccountProvisioningParameters)
/// The app's home screen. /// The app's home screen.
case roomList case roomList
/// A room, shown as the root of the stack (popping any child rooms). /// A room, shown as the root of the stack (popping any child rooms).
@@ -43,6 +48,25 @@ enum AppRoute: Equatable, Hashable {
case chatBackupSettings case chatBackupSettings
/// An external share request e.g. from the ShareExtension /// An external share request e.g. from the ShareExtension
case share(ShareExtensionPayload) case share(ShareExtensionPayload)
/// Whether or not the route should be handled by the authentication flow.
var isAuthenticationRoute: Bool {
switch self {
case .accountProvisioningLink: true
default: false
}
}
}
/// The parameters parsed out of a provisioning link that can be applied to the authentication flow for a streamlined onboarding experience.
struct AccountProvisioningParameters: Hashable {
let accountProvider: String
let loginHint: String?
enum CodingKeys: String, CodingKey {
case accountProvider = "account_provider"
case loginHint = "login_hint"
}
} }
struct AppRouteURLParser { struct AppRouteURLParser {
@@ -53,6 +77,7 @@ struct AppRouteURLParser {
AppGroupURLParser(), AppGroupURLParser(),
MatrixPermalinkParser(), MatrixPermalinkParser(),
ElementWebURLParser(domains: appSettings.elementWebHosts), ElementWebURLParser(domains: appSettings.elementWebHosts),
AccountProvisioningURLParser(domain: appSettings.accountProvisioningHost),
ElementCallURLParser() ElementCallURLParser()
] ]
} }
@@ -68,6 +93,8 @@ struct AppRouteURLParser {
} }
} }
// MARK: - URL Parsers
/// Represents a type that can parse a `URL` into an `AppRoute`. /// Represents a type that can parse a `URL` into an `AppRoute`.
/// ///
/// The following Universal Links are missing parsers. /// The following Universal Links are missing parsers.
@@ -76,7 +103,8 @@ protocol URLParser {
func route(from url: URL) -> AppRoute? func route(from url: URL) -> AppRoute?
} }
struct AppGroupURLParser: URLParser { /// The parser for routes that come from app extensions such as the Share Extension.
private struct AppGroupURLParser: URLParser {
func route(from url: URL) -> AppRoute? { func route(from url: URL) -> AppRoute? {
guard let scheme = url.scheme, guard let scheme = url.scheme,
scheme == InfoPlistReader.app.appScheme, scheme == InfoPlistReader.app.appScheme,
@@ -101,7 +129,7 @@ struct AppGroupURLParser: URLParser {
} }
/// The parser for Element Call links. This always returns a `.genericCallLink`. /// The parser for Element Call links. This always returns a `.genericCallLink`.
struct ElementCallURLParser: URLParser { private struct ElementCallURLParser: URLParser {
private let knownHosts = ["call.element.io"] private let knownHosts = ["call.element.io"]
private let customSchemeURLQueryParameterName = "url" private let customSchemeURLQueryParameterName = "url"
@@ -139,7 +167,7 @@ struct ElementCallURLParser: URLParser {
} }
} }
struct MatrixPermalinkParser: URLParser { private struct MatrixPermalinkParser: URLParser {
func route(from url: URL) -> AppRoute? { func route(from url: URL) -> AppRoute? {
guard let entity = parseMatrixEntityFrom(uri: url.absoluteString) else { return nil } guard let entity = parseMatrixEntityFrom(uri: url.absoluteString) else { return nil }
@@ -158,7 +186,7 @@ struct MatrixPermalinkParser: URLParser {
} }
} }
struct ElementWebURLParser: URLParser { private struct ElementWebURLParser: URLParser {
let domains: [String] let domains: [String]
let paths = ["room", "user"] let paths = ["room", "user"]
@@ -187,3 +215,21 @@ struct ElementWebURLParser: URLParser {
return url return url
} }
} }
/// The parser for user provisioning links.
private struct AccountProvisioningURLParser: URLParser {
let domain: String
func route(from url: URL) -> AppRoute? {
guard url.host() == domain else { return nil }
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let serverName = components.queryItems?.first(where: { $0.name == AccountProvisioningParameters.CodingKeys.accountProvider.rawValue })?.value else {
return nil
}
let loginHint = components.queryItems?.first { $0.name == AccountProvisioningParameters.CodingKeys.loginHint.rawValue }?.value
return .accountProvisioningLink(.init(accountProvider: serverName, loginHint: loginHint))
}
}
@@ -31,6 +31,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The initial screen shown when you first launch the app. /// The initial screen shown when you first launch the app.
case startScreen case startScreen
/// The initial screen with a provisioning link applied.
case provisionedStartScreen
/// The screen used for the whole QR Code flow. /// The screen used for the whole QR Code flow.
case qrCodeLoginScreen case qrCodeLoginScreen
@@ -55,12 +57,13 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The flow is being started. /// The flow is being started.
case start case start
/// Modify the flow using the provisioning parameters in the `userInfo`.
case applyProvisioningParameters
/// The user would like to login with a QR code. /// The user would like to login with a QR code.
case loginWithQR case loginWithQR
/// The user would like to login without a QR code. /// Show the server confirmation screen.
case loginManually case confirmServer(AuthenticationFlow)
/// The user would like to register a new account.
case register
/// The user encountered a problem. /// The user encountered a problem.
case reportProblem case reportProblem
@@ -74,19 +77,19 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The user is no longer selecting a server. /// The user is no longer selecting a server.
case dismissedServerSelection case dismissedServerSelection
/// Show the web authentication session for OIDC. /// Show the web authentication session for OIDC (using the parameters in the `userInfo`).
case continueWithOIDC case continueWithOIDC
/// The web authentication session was aborted. /// The web authentication session was aborted.
case cancelledOIDCAuthentication case cancelledOIDCAuthentication(previousState: State)
/// Show the screen to login with password. /// Show the screen to login with password (with the optional login hint in the `userInfo`).
case continueWithPassword case continueWithPassword
/// The password login was aborted. /// The password login was aborted.
case cancelledPasswordLogin case cancelledPasswordLogin(previousState: State)
/// The user has finished reporting a problem (or viewing the logs). /// The user has finished reporting a problem (or viewing the logs).
case bugReportFlowComplete case bugReportFlowComplete(previousState: State)
/// The user has successfully signed in! /// The user has successfully signed in. The new session can be found in the `userInfo`.
case signedIn case signedIn
} }
@@ -128,18 +131,53 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
} }
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
fatalError() switch appRoute {
case .accountProvisioningLink(let provisioningParameters):
if stateMachine.state != .startScreen {
clearRoute(animated: animated)
}
stateMachine.tryEvent(.applyProvisioningParameters, userInfo: provisioningParameters)
default:
fatalError()
}
} }
func clearRoute(animated: Bool) { func clearRoute(animated: Bool) {
fatalError() switch stateMachine.state {
case .initial, .startScreen, .provisionedStartScreen:
break
case .qrCodeLoginScreen:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR) // Needs to be handled manually.
case .serverConfirmationScreen:
navigationStackCoordinator.popToRoot(animated: animated)
case .serverSelectionScreen:
navigationStackCoordinator.setSheetCoordinator(nil)
navigationStackCoordinator.popToRoot(animated: animated)
case .oidcAuthentication:
oidcPresenter?.cancel()
navigationStackCoordinator.popToRoot(animated: animated)
case .loginScreen:
navigationStackCoordinator.popToRoot(animated: animated)
case .bugReportFlow:
navigationStackCoordinator.setSheetCoordinator(nil)
case .complete:
fatalError()
}
} }
// MARK: - Setup // MARK: - Setup
private func configureStateMachine() { private func configureStateMachine() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .startScreen]) { [weak self] _ in stateMachine.addRoutes(event: .start, transitions: [.initial => .startScreen]) { [weak self] _ in
self?.showStartScreen() self?.showStartScreen(fromState: .initial)
}
stateMachine.addRoutes(event: .applyProvisioningParameters, transitions: [.initial => .provisionedStartScreen,
.startScreen => .provisionedStartScreen]) { [weak self] context in
guard let provisioningParameters = context.userInfo as? AccountProvisioningParameters else { fatalError("The authentication configuration is missing.") }
self?.showStartScreen(fromState: context.fromState, applying: provisioningParameters)
} }
// QR Code // QR Code
@@ -151,10 +189,10 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
// Manual Authentication // Manual Authentication
stateMachine.addRoutes(event: .loginManually, transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in stateMachine.addRoutes(event: .confirmServer(.login), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
self?.showServerConfirmationScreen(authenticationFlow: .login) self?.showServerConfirmationScreen(authenticationFlow: .login)
} }
stateMachine.addRoutes(event: .register, transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in stateMachine.addRoutes(event: .confirmServer(.register), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
self?.showServerConfirmationScreen(authenticationFlow: .register) self?.showServerConfirmationScreen(authenticationFlow: .register)
} }
stateMachine.addRoutes(event: .cancelledServerConfirmation, transitions: [.serverConfirmationScreen => .startScreen]) stateMachine.addRoutes(event: .cancelledServerConfirmation, transitions: [.serverConfirmationScreen => .startScreen])
@@ -167,25 +205,32 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
} }
stateMachine.addRoutes(event: .dismissedServerSelection, transitions: [.serverSelectionScreen => .serverConfirmationScreen]) stateMachine.addRoutes(event: .dismissedServerSelection, transitions: [.serverSelectionScreen => .serverConfirmationScreen])
stateMachine.addRoutes(event: .continueWithOIDC, transitions: [.serverConfirmationScreen => .oidcAuthentication]) { [weak self] context in stateMachine.addRoutes(event: .continueWithOIDC, transitions: [.serverConfirmationScreen => .oidcAuthentication,
.provisionedStartScreen => .oidcAuthentication]) { [weak self] context in
guard let (oidcData, window) = context.userInfo as? (OIDCAuthorizationDataProxy, UIWindow) else { guard let (oidcData, window) = context.userInfo as? (OIDCAuthorizationDataProxy, UIWindow) else {
fatalError("Missing the OIDC data and presentation anchor.") fatalError("Missing the OIDC data and presentation anchor.")
} }
self?.showOIDCAuthentication(oidcData: oidcData, presentationAnchor: window) self?.showOIDCAuthentication(oidcData: oidcData, presentationAnchor: window, fromState: context.fromState)
} }
stateMachine.addRoutes(event: .cancelledOIDCAuthentication, transitions: [.oidcAuthentication => .serverConfirmationScreen]) stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .serverConfirmationScreen), transitions: [.oidcAuthentication => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .provisionedStartScreen), transitions: [.oidcAuthentication => .provisionedStartScreen])
stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen]) { [weak self] _ in stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen,
self?.showLoginScreen() .provisionedStartScreen => .loginScreen]) { [weak self] context in
let loginHint = context.userInfo as? String
self?.showLoginScreen(loginHint: loginHint, fromState: context.fromState)
} }
stateMachine.addRoutes(event: .cancelledPasswordLogin, transitions: [.loginScreen => .serverConfirmationScreen]) stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverConfirmationScreen), transitions: [.loginScreen => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .provisionedStartScreen), transitions: [.loginScreen => .provisionedStartScreen])
// Bug Report // Bug Report
stateMachine.addRoutes(event: .reportProblem, transitions: [.startScreen => .bugReportFlow]) { [weak self] _ in stateMachine.addRoutes(event: .reportProblem, transitions: [.startScreen => .bugReportFlow,
self?.startBugReportFlow() .provisionedStartScreen => .bugReportFlow]) { [weak self] context in
self?.startBugReportFlow(fromState: context.fromState)
} }
stateMachine.addRoutes(event: .bugReportFlowComplete, transitions: [.bugReportFlow => .startScreen]) stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .startScreen), transitions: [.bugReportFlow => .startScreen])
stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .provisionedStartScreen), transitions: [.bugReportFlow => .provisionedStartScreen])
// Completion // Completion
@@ -208,9 +253,12 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
} }
} }
private func showStartScreen() { private func showStartScreen(fromState: State, applying provisioningParameters: AccountProvisioningParameters? = nil) {
let parameters = AuthenticationStartScreenParameters(showCreateAccountButton: appSettings.showCreateAccountButton, let parameters = AuthenticationStartScreenParameters(authenticationService: authenticationService,
isBugReportServiceEnabled: bugReportService.isEnabled) provisioningParameters: provisioningParameters,
isBugReportServiceEnabled: bugReportService.isEnabled,
appSettings: appSettings,
userIndicatorController: userIndicatorController)
let coordinator = AuthenticationStartScreenCoordinator(parameters: parameters) let coordinator = AuthenticationStartScreenCoordinator(parameters: parameters)
coordinator.actions coordinator.actions
@@ -220,19 +268,26 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .loginWithQR: case .loginWithQR:
stateMachine.tryEvent(.loginWithQR) stateMachine.tryEvent(.loginWithQR)
case .loginManually: case .login:
stateMachine.tryEvent(.loginManually) stateMachine.tryEvent(.confirmServer(.login))
case .register: case .register:
stateMachine.tryEvent(.register) stateMachine.tryEvent(.confirmServer(.register))
case .reportProblem: case .reportProblem:
stateMachine.tryEvent(.reportProblem) stateMachine.tryEvent(.reportProblem)
case .loginDirectlyWithOIDC(let oidcData, let window):
stateMachine.tryEvent(.continueWithOIDC, userInfo: (oidcData, window))
case .loginDirectlyWithPassword(let loginHint):
stateMachine.tryEvent(.continueWithPassword, userInfo: loginHint)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
navigationRootCoordinator.setRootCoordinator(navigationStackCoordinator) if fromState == .initial {
navigationRootCoordinator.setRootCoordinator(navigationStackCoordinator)
}
} }
// MARK: - QR Code // MARK: - QR Code
@@ -249,7 +304,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .signInManually: case .signInManually:
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR) stateMachine.tryEvent(.cancelledLoginWithQR)
stateMachine.tryEvent(.loginManually) stateMachine.tryEvent(.confirmServer(.login))
case .cancel: case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR) stateMachine.tryEvent(.cancelledLoginWithQR)
@@ -324,7 +379,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
} }
} }
private func showOIDCAuthentication(oidcData: OIDCAuthorizationDataProxy, presentationAnchor: UIWindow) { private func showOIDCAuthentication(oidcData: OIDCAuthorizationDataProxy, presentationAnchor: UIWindow, fromState: State) {
let presenter = OIDCAuthenticationPresenter(authenticationService: authenticationService, let presenter = OIDCAuthenticationPresenter(authenticationService: authenticationService,
oidcRedirectURL: appSettings.oidcRedirectURL, oidcRedirectURL: appSettings.oidcRedirectURL,
presentationAnchor: presentationAnchor, presentationAnchor: presentationAnchor,
@@ -336,15 +391,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .success(let userSession): case .success(let userSession):
stateMachine.tryEvent(.signedIn, userInfo: userSession) stateMachine.tryEvent(.signedIn, userInfo: userSession)
case .failure: case .failure:
stateMachine.tryEvent(.cancelledOIDCAuthentication) stateMachine.tryEvent(.cancelledOIDCAuthentication(previousState: fromState))
// Nothing more to do, the alerts are handled by the presenter. // Nothing more to do, the alerts are handled by the presenter.
} }
oidcPresenter = nil oidcPresenter = nil
} }
} }
private func showLoginScreen() { private func showLoginScreen(loginHint: String?, fromState: State) {
let parameters = LoginScreenCoordinatorParameters(authenticationService: authenticationService, let parameters = LoginScreenCoordinatorParameters(authenticationService: authenticationService,
loginHint: loginHint,
userIndicatorController: userIndicatorController, userIndicatorController: userIndicatorController,
analytics: analytics) analytics: analytics)
let coordinator = LoginScreenCoordinator(parameters: parameters) let coordinator = LoginScreenCoordinator(parameters: parameters)
@@ -364,13 +420,13 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
.store(in: &cancellables) .store(in: &cancellables)
navigationStackCoordinator.push(coordinator) { [weak self] in navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.cancelledPasswordLogin) self?.stateMachine.tryEvent(.cancelledPasswordLogin(previousState: fromState))
} }
} }
// MARK: - Bug Report // MARK: - Bug Report
private func startBugReportFlow() { private func startBugReportFlow(fromState: State) {
let coordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(navigationStackCoordinator), let coordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(navigationStackCoordinator),
userIndicatorController: userIndicatorController, userIndicatorController: userIndicatorController,
bugReportService: bugReportService, bugReportService: bugReportService,
@@ -378,7 +434,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actionsPublisher.sink { [weak self] action in coordinator.actionsPublisher.sink { [weak self] action in
switch action { switch action {
case .complete: case .complete:
self?.stateMachine.tryEvent(.bugReportFlowComplete) self?.stateMachine.tryEvent(.bugReportFlowComplete(previousState: fromState))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -77,6 +77,8 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
switch appRoute { switch appRoute {
case .accountProvisioningLink:
break // We always ignore this flow when logged in.
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias, case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile, .roomDetails, .roomMemberDetails, .userProfile,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias, .event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
@@ -214,7 +214,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} }
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias: case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
break // These are converted to a room ID route one level above. break // These are converted to a room ID route one level above.
case .roomList, .userProfile, .call, .genericCallLink, .settings, .chatBackupSettings: case .accountProvisioningLink, .roomList, .userProfile, .call, .genericCallLink, .settings, .chatBackupSettings:
break // These routes can't be handled. break // These routes can't be handled.
} }
} }
@@ -214,6 +214,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} else { } else {
stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated)) stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated))
} }
case .accountProvisioningLink:
break // We always ignore this flow when logged in.
} }
} }
@@ -4273,16 +4273,16 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
//MARK: - urlForOidc //MARK: - urlForOidc
open var urlForOidcOidcConfigurationPromptThrowableError: Error? open var urlForOidcOidcConfigurationPromptLoginHintThrowableError: Error?
var urlForOidcOidcConfigurationPromptUnderlyingCallsCount = 0 var urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount = 0
open var urlForOidcOidcConfigurationPromptCallsCount: Int { open var urlForOidcOidcConfigurationPromptLoginHintCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return urlForOidcOidcConfigurationPromptUnderlyingCallsCount return urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = urlForOidcOidcConfigurationPromptUnderlyingCallsCount returnValue = urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount
} }
return returnValue! return returnValue!
@@ -4290,29 +4290,29 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
urlForOidcOidcConfigurationPromptUnderlyingCallsCount = newValue urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
urlForOidcOidcConfigurationPromptUnderlyingCallsCount = newValue urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount = newValue
} }
} }
} }
} }
open var urlForOidcOidcConfigurationPromptCalled: Bool { open var urlForOidcOidcConfigurationPromptLoginHintCalled: Bool {
return urlForOidcOidcConfigurationPromptCallsCount > 0 return urlForOidcOidcConfigurationPromptLoginHintCallsCount > 0
} }
open var urlForOidcOidcConfigurationPromptReceivedArguments: (oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?)? open var urlForOidcOidcConfigurationPromptLoginHintReceivedArguments: (oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?, loginHint: String?)?
open var urlForOidcOidcConfigurationPromptReceivedInvocations: [(oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?)] = [] open var urlForOidcOidcConfigurationPromptLoginHintReceivedInvocations: [(oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?, loginHint: String?)] = []
var urlForOidcOidcConfigurationPromptUnderlyingReturnValue: OAuthAuthorizationData! var urlForOidcOidcConfigurationPromptLoginHintUnderlyingReturnValue: OAuthAuthorizationData!
open var urlForOidcOidcConfigurationPromptReturnValue: OAuthAuthorizationData! { open var urlForOidcOidcConfigurationPromptLoginHintReturnValue: OAuthAuthorizationData! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return urlForOidcOidcConfigurationPromptUnderlyingReturnValue return urlForOidcOidcConfigurationPromptLoginHintUnderlyingReturnValue
} else { } else {
var returnValue: OAuthAuthorizationData? = nil var returnValue: OAuthAuthorizationData? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = urlForOidcOidcConfigurationPromptUnderlyingReturnValue returnValue = urlForOidcOidcConfigurationPromptLoginHintUnderlyingReturnValue
} }
return returnValue! return returnValue!
@@ -4320,29 +4320,29 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
urlForOidcOidcConfigurationPromptUnderlyingReturnValue = newValue urlForOidcOidcConfigurationPromptLoginHintUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
urlForOidcOidcConfigurationPromptUnderlyingReturnValue = newValue urlForOidcOidcConfigurationPromptLoginHintUnderlyingReturnValue = newValue
} }
} }
} }
} }
open var urlForOidcOidcConfigurationPromptClosure: ((OidcConfiguration, OidcPrompt?) async throws -> OAuthAuthorizationData)? open var urlForOidcOidcConfigurationPromptLoginHintClosure: ((OidcConfiguration, OidcPrompt?, String?) async throws -> OAuthAuthorizationData)?
open override func urlForOidc(oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?) async throws -> OAuthAuthorizationData { open override func urlForOidc(oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?, loginHint: String?) async throws -> OAuthAuthorizationData {
if let error = urlForOidcOidcConfigurationPromptThrowableError { if let error = urlForOidcOidcConfigurationPromptLoginHintThrowableError {
throw error throw error
} }
urlForOidcOidcConfigurationPromptCallsCount += 1 urlForOidcOidcConfigurationPromptLoginHintCallsCount += 1
urlForOidcOidcConfigurationPromptReceivedArguments = (oidcConfiguration: oidcConfiguration, prompt: prompt) urlForOidcOidcConfigurationPromptLoginHintReceivedArguments = (oidcConfiguration: oidcConfiguration, prompt: prompt, loginHint: loginHint)
DispatchQueue.main.async { DispatchQueue.main.async {
self.urlForOidcOidcConfigurationPromptReceivedInvocations.append((oidcConfiguration: oidcConfiguration, prompt: prompt)) self.urlForOidcOidcConfigurationPromptLoginHintReceivedInvocations.append((oidcConfiguration: oidcConfiguration, prompt: prompt, loginHint: loginHint))
} }
if let urlForOidcOidcConfigurationPromptClosure = urlForOidcOidcConfigurationPromptClosure { if let urlForOidcOidcConfigurationPromptLoginHintClosure = urlForOidcOidcConfigurationPromptLoginHintClosure {
return try await urlForOidcOidcConfigurationPromptClosure(oidcConfiguration, prompt) return try await urlForOidcOidcConfigurationPromptLoginHintClosure(oidcConfiguration, prompt, loginHint)
} else { } else {
return urlForOidcOidcConfigurationPromptReturnValue return urlForOidcOidcConfigurationPromptLoginHintReturnValue
} }
} }
@@ -10658,6 +10658,71 @@ open class QrCodeDataSDKMock: MatrixRustSDK.QrCodeData, @unchecked Sendable {
static func reset() static func reset()
{ {
} }
//MARK: - serverName
var serverNameUnderlyingCallsCount = 0
open var serverNameCallsCount: Int {
get {
if Thread.isMainThread {
return serverNameUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = serverNameUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
serverNameUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
serverNameUnderlyingCallsCount = newValue
}
}
}
}
open var serverNameCalled: Bool {
return serverNameCallsCount > 0
}
var serverNameUnderlyingReturnValue: String?
open var serverNameReturnValue: String? {
get {
if Thread.isMainThread {
return serverNameUnderlyingReturnValue
} else {
var returnValue: String?? = nil
DispatchQueue.main.sync {
returnValue = serverNameUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
serverNameUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
serverNameUnderlyingReturnValue = newValue
}
}
}
}
open var serverNameClosure: (() -> String?)?
open override func serverName() -> String? {
serverNameCallsCount += 1
if let serverNameClosure = serverNameClosure {
return serverNameClosure()
} else {
return serverNameReturnValue
}
}
} }
open class RoomSDKMock: MatrixRustSDK.Room, @unchecked Sendable { open class RoomSDKMock: MatrixRustSDK.Room, @unchecked Sendable {
init() { init() {
@@ -41,7 +41,7 @@ extension ClientSDKMock {
slidingSyncVersionReturnValue = configuration.slidingSyncVersion slidingSyncVersionReturnValue = configuration.slidingSyncVersion
userIdServerNameThrowableError = MockError.generic userIdServerNameThrowableError = MockError.generic
serverReturnValue = "https://\(configuration.serverAddress)" serverReturnValue = "https://\(configuration.serverAddress)"
urlForOidcOidcConfigurationPromptReturnValue = OAuthAuthorizationDataSDKMock(configuration: configuration) urlForOidcOidcConfigurationPromptLoginHintReturnValue = OAuthAuthorizationDataSDKMock(configuration: configuration)
loginUsernamePasswordInitialDeviceNameDeviceIdClosure = { username, password, _, _ in loginUsernamePasswordInitialDeviceNameDeviceIdClosure = { username, password, _, _ in
guard username == configuration.validCredentials.username, guard username == configuration.validCredentials.username,
password == configuration.validCredentials.password else { password == configuration.validCredentials.password else {
@@ -78,6 +78,7 @@ enum A11yIdentifiers {
let screenshot = "bug_report-screenshot" let screenshot = "bug_report-screenshot"
let removeScreenshot = "bug_report-remove_screenshot" let removeScreenshot = "bug_report-remove_screenshot"
let attachScreenshot = "bug-report-attach_screenshot" let attachScreenshot = "bug-report-attach_screenshot"
let cancel = "bug_report-cancel"
} }
struct ChangeServer { struct ChangeServer {
@@ -121,6 +122,7 @@ enum A11yIdentifiers {
struct AuthenticationStartScreen { struct AuthenticationStartScreen {
let signIn = "authentication_start-sign_in" let signIn = "authentication_start-sign_in"
let signInWithQr = "authentication_start-sign_in_with_qr" let signInWithQr = "authentication_start-sign_in_with_qr"
let reportAProblem = "authentication_start-report_a_problem"
let hidden = "authentication_start-hidden" let hidden = "authentication_start-hidden"
} }
@@ -11,6 +11,8 @@ import SwiftUI
struct LoginScreenCoordinatorParameters { struct LoginScreenCoordinatorParameters {
/// The service used to authenticate the user. /// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProtocol let authenticationService: AuthenticationServiceProtocol
/// An optional hint that can be used to pre-fill the form.
let loginHint: String?
let userIndicatorController: UserIndicatorControllerProtocol let userIndicatorController: UserIndicatorControllerProtocol
let analytics: AnalyticsService let analytics: AnalyticsService
} }
@@ -42,6 +44,7 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters self.parameters = parameters
viewModel = LoginScreenViewModel(authenticationService: parameters.authenticationService, viewModel = LoginScreenViewModel(authenticationService: parameters.authenticationService,
loginHint: parameters.loginHint,
userIndicatorController: parameters.userIndicatorController, userIndicatorController: parameters.userIndicatorController,
analytics: parameters.analytics) analytics: parameters.analytics)
} }
@@ -21,13 +21,21 @@ class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtoc
} }
init(authenticationService: AuthenticationServiceProtocol, init(authenticationService: AuthenticationServiceProtocol,
loginHint: String?,
userIndicatorController: UserIndicatorControllerProtocol, userIndicatorController: UserIndicatorControllerProtocol,
analytics: AnalyticsService) { analytics: AnalyticsService) {
self.authenticationService = authenticationService self.authenticationService = authenticationService
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.analytics = analytics self.analytics = analytics
let viewState = LoginScreenViewState(homeserver: authenticationService.homeserver.value) let username = switch loginHint {
case .some(let hint) where hint.hasPrefix("mxid:"): String(hint.dropFirst(5)) // MSC4198
case .some(let hint): hint
case .none: ""
}
let viewState = LoginScreenViewState(homeserver: authenticationService.homeserver.value,
bindings: LoginScreenBindings(username: username))
super.init(initialViewState: viewState) super.init(initialViewState: viewState)
@@ -161,6 +161,7 @@ struct LoginScreen_Previews: PreviewProvider, TestablePreview {
Task { await authenticationService.configure(for: homeserverAddress, flow: .login) } Task { await authenticationService.configure(for: homeserverAddress, flow: .login) }
let viewModel = LoginScreenViewModel(authenticationService: authenticationService, let viewModel = LoginScreenViewModel(authenticationService: authenticationService,
loginHint: nil,
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics) analytics: ServiceLocator.shared.analytics)
@@ -15,6 +15,8 @@ class OIDCAuthenticationPresenter: NSObject {
private let presentationAnchor: UIWindow private let presentationAnchor: UIWindow
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private var activeSession: ASWebAuthenticationSession?
init(authenticationService: AuthenticationServiceProtocol, init(authenticationService: AuthenticationServiceProtocol,
oidcRedirectURL: URL, oidcRedirectURL: URL,
presentationAnchor: UIWindow, presentationAnchor: UIWindow,
@@ -36,9 +38,12 @@ class OIDCAuthenticationPresenter: NSObject {
session.prefersEphemeralWebBrowserSession = false session.prefersEphemeralWebBrowserSession = false
session.presentationContextProvider = self session.presentationContextProvider = self
activeSession = session
session.start() session.start()
} }
activeSession = nil
guard let url else { guard let url else {
// Check for user cancellation to avoid showing an alert in that instance. // Check for user cancellation to avoid showing an alert in that instance.
if let nsError = error as? NSError, if let nsError = error as? NSError,
@@ -76,6 +81,10 @@ class OIDCAuthenticationPresenter: NSObject {
} }
} }
func cancel() {
activeSession?.cancel()
}
private static let loadingIndicatorID = "\(OIDCAuthenticationPresenter.self)-Loading" private static let loadingIndicatorID = "\(OIDCAuthenticationPresenter.self)-Loading"
private func startLoading(delay: Duration? = nil) { private func startLoading(delay: Duration? = nil) {
@@ -101,7 +101,7 @@ class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType,
startLoading() // Uses the same ID, so no need to worry if the indicator already exists startLoading() // Uses the same ID, so no need to worry if the indicator already exists
defer { stopLoading() } defer { stopLoading() }
switch await authenticationService.urlForOIDCLogin() { switch await authenticationService.urlForOIDCLogin(loginHint: nil) {
case .success(let oidcData): case .success(let oidcData):
actionsSubject.send(.continueWithOIDC(data: oidcData, window: window)) actionsSubject.send(.continueWithOIDC(data: oidcData, window: window))
case .failure: case .failure:
@@ -134,7 +134,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
startLoading() startLoading()
Task { Task {
switch await authenticationService.urlForOIDCLogin() { switch await authenticationService.urlForOIDCLogin(loginHint: nil) {
case .failure(let error): case .failure(let error):
stopLoading() stopLoading()
handleError(error) handleError(error)
@@ -9,8 +9,11 @@ import Combine
import SwiftUI import SwiftUI
struct AuthenticationStartScreenParameters { struct AuthenticationStartScreenParameters {
let showCreateAccountButton: Bool let authenticationService: AuthenticationServiceProtocol
let provisioningParameters: AccountProvisioningParameters?
let isBugReportServiceEnabled: Bool let isBugReportServiceEnabled: Bool
let appSettings: AppSettings
let userIndicatorController: UserIndicatorControllerProtocol
} }
final class AuthenticationStartScreenCoordinator: CoordinatorProtocol { final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
@@ -23,8 +26,11 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
} }
init(parameters: AuthenticationStartScreenParameters) { init(parameters: AuthenticationStartScreenParameters) {
viewModel = AuthenticationStartScreenViewModel(showCreateAccountButton: parameters.showCreateAccountButton, viewModel = AuthenticationStartScreenViewModel(authenticationService: parameters.authenticationService,
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled) provisioningParameters: parameters.provisioningParameters,
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController)
} }
// MARK: - Public // MARK: - Public
@@ -35,14 +41,19 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
guard let self else { return } guard let self else { return }
switch action { switch action {
case .loginManually:
actionsSubject.send(.loginManually)
case .loginWithQR: case .loginWithQR:
actionsSubject.send(.loginWithQR) actionsSubject.send(.loginWithQR)
case .login:
actionsSubject.send(.login)
case .register: case .register:
actionsSubject.send(.register) actionsSubject.send(.register)
case .reportProblem: case .reportProblem:
actionsSubject.send(.reportProblem) actionsSubject.send(.reportProblem)
case .loginDirectlyWithOIDC(let data, let window):
actionsSubject.send(.loginDirectlyWithOIDC(data: data, window: window))
case .loginDirectlyWithPassword(let loginHint):
actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -11,27 +11,60 @@ import SwiftUI
enum AuthenticationStartScreenCoordinatorAction { enum AuthenticationStartScreenCoordinatorAction {
case loginWithQR case loginWithQR
case loginManually case login
case register case register
case reportProblem case reportProblem
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
case loginDirectlyWithPassword(loginHint: String?)
} }
enum AuthenticationStartScreenViewModelAction { enum AuthenticationStartScreenViewModelAction: Equatable {
case loginWithQR case loginWithQR
case loginManually case login
case register case register
case reportProblem case reportProblem
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
case loginDirectlyWithPassword(loginHint: String?)
} }
struct AuthenticationStartScreenViewState: BindableState { struct AuthenticationStartScreenViewState: BindableState {
/// The presentation anchor used for OIDC authentication.
var window: UIWindow?
let serverName: String?
let showCreateAccountButton: Bool let showCreateAccountButton: Bool
let isQRCodeLoginEnabled: Bool let showQRCodeLoginButton: Bool
let isBugReportServiceEnabled: Bool let showReportProblemButton: Bool
var bindings = AuthenticationStartScreenViewStateBindings()
var loginButtonTitle: String {
if let serverName {
L10n.screenOnboardingSignInTo(serverName)
} else if showQRCodeLoginButton {
L10n.screenOnboardingSignInManually
} else {
L10n.actionContinue
}
}
}
struct AuthenticationStartScreenViewStateBindings {
var alertInfo: AlertInfo<AuthenticationStartScreenAlertType>?
}
enum AuthenticationStartScreenAlertType {
case genericError
} }
enum AuthenticationStartScreenViewAction { enum AuthenticationStartScreenViewAction {
/// Updates the window used as the OIDC presentation anchor.
case updateWindow(UIWindow)
case loginWithQR case loginWithQR
case loginManually case login
case register case register
case reportProblem case reportProblem
} }
@@ -11,28 +11,105 @@ import SwiftUI
typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2<AuthenticationStartScreenViewState, AuthenticationStartScreenViewAction> typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2<AuthenticationStartScreenViewState, AuthenticationStartScreenViewAction>
class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol { class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol {
private let authenticationService: AuthenticationServiceProtocol
private let provisioningParameters: AccountProvisioningParameters?
private let appSettings: AppSettings
private let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<AuthenticationStartScreenViewModelAction, Never> = .init() private var actionsSubject: PassthroughSubject<AuthenticationStartScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<AuthenticationStartScreenViewModelAction, Never> { var actions: AnyPublisher<AuthenticationStartScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
init(showCreateAccountButton: Bool, isBugReportServiceEnabled: Bool) { init(authenticationService: AuthenticationServiceProtocol,
super.init(initialViewState: AuthenticationStartScreenViewState(showCreateAccountButton: showCreateAccountButton, provisioningParameters: AccountProvisioningParameters?,
isQRCodeLoginEnabled: !ProcessInfo.processInfo.isiOSAppOnMac, isBugReportServiceEnabled: Bool,
isBugReportServiceEnabled: isBugReportServiceEnabled)) appSettings: AppSettings,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.provisioningParameters = provisioningParameters
self.appSettings = appSettings
self.userIndicatorController = userIndicatorController
// We only show the "Sign in to …" button when using a provisioning link.
let showCreateAccountButton = appSettings.showCreateAccountButton && provisioningParameters == nil
let showQRCodeLoginButton = !ProcessInfo.processInfo.isiOSAppOnMac && provisioningParameters == nil
super.init(initialViewState: AuthenticationStartScreenViewState(serverName: provisioningParameters?.accountProvider,
showCreateAccountButton: showCreateAccountButton,
showQRCodeLoginButton: showQRCodeLoginButton,
showReportProblemButton: isBugReportServiceEnabled))
} }
override func process(viewAction: AuthenticationStartScreenViewAction) { override func process(viewAction: AuthenticationStartScreenViewAction) {
switch viewAction { switch viewAction {
case .updateWindow(let window):
guard state.window != window else { return }
state.window = window
case .loginWithQR: case .loginWithQR:
actionsSubject.send(.loginWithQR) actionsSubject.send(.loginWithQR)
case .loginManually: case .login:
actionsSubject.send(.loginManually) Task { await login() }
case .register: case .register:
actionsSubject.send(.register) actionsSubject.send(.register)
case .reportProblem: case .reportProblem:
actionsSubject.send(.reportProblem) actionsSubject.send(.reportProblem)
} }
} }
// MARK: - Private
private func login() async {
if let provisioningParameters {
await configureProvisionedServer(with: provisioningParameters)
} else {
actionsSubject.send(.login) // No need to configure anything here, continue the flow.
}
}
private func configureProvisionedServer(with provisioningParameters: AccountProvisioningParameters) async {
startLoading()
defer { stopLoading() }
guard case .success = await authenticationService.configure(for: provisioningParameters.accountProvider, flow: .login) else {
// As the server was provisioned, we don't worry about the specifics and show a generic error to the user.
displayError()
return
}
guard authenticationService.homeserver.value.loginMode.supportsOIDCFlow else {
actionsSubject.send(.loginDirectlyWithPassword(loginHint: provisioningParameters.loginHint))
return
}
guard let window = state.window else {
displayError()
return
}
switch await authenticationService.urlForOIDCLogin(loginHint: provisioningParameters.loginHint) {
case .success(let oidcData):
actionsSubject.send(.loginDirectlyWithOIDC(data: oidcData, window: window))
case .failure:
displayError()
}
}
private let loadingIndicatorID = "\(AuthenticationStartScreenViewModel.self)-Loading"
private func startLoading() {
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorID,
type: .modal,
title: L10n.commonLoading,
persistent: true))
}
private func stopLoading() {
userIndicatorController.retractIndicatorWithId(loadingIndicatorID)
}
private func displayError() {
state.bindings.alertInfo = AlertInfo(id: .genericError)
}
} }
@@ -35,7 +35,7 @@ struct AuthenticationStartScreen: View {
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
if context.viewState.isBugReportServiceEnabled { if context.viewState.showReportProblemButton {
Button { Button {
context.send(viewAction: .reportProblem) context.send(viewAction: .reportProblem)
} label: { } label: {
@@ -45,6 +45,7 @@ struct AuthenticationStartScreen: View {
.padding(.bottom) .padding(.bottom)
} }
.frame(width: geometry.size.width) .frame(width: geometry.size.width)
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.reportAProblem)
} }
} }
} }
@@ -52,6 +53,9 @@ struct AuthenticationStartScreen: View {
.background { .background {
AuthenticationStartScreenBackgroundImage() AuthenticationStartScreenBackgroundImage()
} }
.introspect(.window, on: .supportedVersions) { window in
context.send(viewAction: .updateWindow(window))
}
} }
var content: some View { var content: some View {
@@ -89,7 +93,7 @@ struct AuthenticationStartScreen: View {
/// The main action buttons. /// The main action buttons.
var buttons: some View { var buttons: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
if context.viewState.isQRCodeLoginEnabled { if context.viewState.showQRCodeLoginButton {
Button { context.send(viewAction: .loginWithQR) } label: { Button { context.send(viewAction: .loginWithQR) } label: {
Label(L10n.screenOnboardingSignInWithQrCode, icon: \.qrCode) Label(L10n.screenOnboardingSignInWithQrCode, icon: \.qrCode)
} }
@@ -97,8 +101,8 @@ struct AuthenticationStartScreen: View {
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signInWithQr) .accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signInWithQr)
} }
Button { context.send(viewAction: .loginManually) } label: { Button { context.send(viewAction: .login) } label: {
Text(context.viewState.isQRCodeLoginEnabled ? L10n.screenOnboardingSignInManually : L10n.actionContinue) Text(context.viewState.loginButtonTitle)
} }
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signIn) .accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signIn)
@@ -120,16 +124,22 @@ struct AuthenticationStartScreen: View {
struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview { struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel() static let viewModel = makeViewModel()
static let bugReportDisabledViewModel = makeViewModel(isBugReportServiceEnabled: false) static let bugReportDisabledViewModel = makeViewModel(isBugReportServiceEnabled: false)
static let provisionedViewModel = makeViewModel(provisionedServerName: "example.com")
static var previews: some View { static var previews: some View {
AuthenticationStartScreen(context: viewModel.context) AuthenticationStartScreen(context: viewModel.context)
.previewDisplayName("Default") .previewDisplayName("Default")
AuthenticationStartScreen(context: bugReportDisabledViewModel.context) AuthenticationStartScreen(context: bugReportDisabledViewModel.context)
.previewDisplayName("Bug report disabled") .previewDisplayName("Bug report disabled")
AuthenticationStartScreen(context: provisionedViewModel.context)
.previewDisplayName("Provisioned")
} }
static func makeViewModel(isBugReportServiceEnabled: Bool = true) -> AuthenticationStartScreenViewModel { static func makeViewModel(isBugReportServiceEnabled: Bool = true, provisionedServerName: String? = nil) -> AuthenticationStartScreenViewModel {
AuthenticationStartScreenViewModel(showCreateAccountButton: true, AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
isBugReportServiceEnabled: isBugReportServiceEnabled) provisioningParameters: provisionedServerName.map { .init(accountProvider: $0, loginHint: nil) },
isBugReportServiceEnabled: isBugReportServiceEnabled,
appSettings: ServiceLocator.shared.settings,
userIndicatorController: UserIndicatorControllerMock())
} }
} }
@@ -122,6 +122,7 @@ struct BugReportScreen: View {
Button(L10n.actionCancel) { Button(L10n.actionCancel) {
context.send(viewAction: .cancel) context.send(viewAction: .cancel)
} }
.accessibilityIdentifier(A11yIdentifiers.bugReportScreen.cancel)
} }
} }
@@ -82,13 +82,14 @@ class AuthenticationService: AuthenticationServiceProtocol {
} }
} }
func urlForOIDCLogin() async -> Result<OIDCAuthorizationDataProxy, AuthenticationServiceError> { func urlForOIDCLogin(loginHint: String?) async -> Result<OIDCAuthorizationDataProxy, AuthenticationServiceError> {
guard let client else { return .failure(.oidcError(.urlFailure)) } guard let client else { return .failure(.oidcError(.urlFailure)) }
do { do {
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429 // The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// let prompt: OidcPrompt = flow == .register ? .create : .consent // let prompt: OidcPrompt = flow == .register ? .create : .consent
let oidcData = try await client.urlForOidc(oidcConfiguration: appSettings.oidcConfiguration.rustValue, let oidcData = try await client.urlForOidc(oidcConfiguration: appSettings.oidcConfiguration.rustValue,
prompt: .consent) prompt: .consent,
loginHint: loginHint)
return .success(OIDCAuthorizationDataProxy(underlyingData: oidcData)) return .success(OIDCAuthorizationDataProxy(underlyingData: oidcData))
} catch { } catch {
MXLog.error("Failed to get URL for OIDC login: \(error)") MXLog.error("Failed to get URL for OIDC login: \(error)")
@@ -41,7 +41,7 @@ protocol AuthenticationServiceProtocol {
/// Sets up the service for login on the specified homeserver address. /// Sets up the service for login on the specified homeserver address.
func configure(for homeserverAddress: String, flow: AuthenticationFlow) async -> Result<Void, AuthenticationServiceError> func configure(for homeserverAddress: String, flow: AuthenticationFlow) async -> Result<Void, AuthenticationServiceError>
/// Performs login using OIDC for the current homeserver. /// Performs login using OIDC for the current homeserver.
func urlForOIDCLogin() async -> Result<OIDCAuthorizationDataProxy, AuthenticationServiceError> func urlForOIDCLogin(loginHint: String?) async -> Result<OIDCAuthorizationDataProxy, AuthenticationServiceError>
/// Asks the SDK to abort an ongoing OIDC login if we didn't get a callback to complete the request with. /// Asks the SDK to abort an ongoing OIDC login if we didn't get a callback to complete the request with.
func abortOIDCLogin(data: OIDCAuthorizationDataProxy) async func abortOIDCLogin(data: OIDCAuthorizationDataProxy) async
/// Completes an OIDC login that was started using ``urlForOIDCLogin``. /// Completes an OIDC login that was started using ``urlForOIDCLogin``.
@@ -120,7 +120,7 @@ class MockScreen: Identifiable {
userIndicatorController: ServiceLocator.shared.userIndicatorController)) userIndicatorController: ServiceLocator.shared.userIndicatorController))
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator return navigationStackCoordinator
case .authenticationFlow: case .authenticationFlow, .provisionedAuthenticationFlow:
let flowCoordinator = AuthenticationFlowCoordinator(authenticationService: AuthenticationService.mock, let flowCoordinator = AuthenticationFlowCoordinator(authenticationService: AuthenticationService.mock,
qrCodeLoginService: QRCodeLoginServiceMock(), qrCodeLoginService: QRCodeLoginServiceMock(),
bugReportService: BugReportServiceMock(.init()), bugReportService: BugReportServiceMock(.init()),
@@ -131,6 +131,11 @@ class MockScreen: Identifiable {
userIndicatorController: ServiceLocator.shared.userIndicatorController) userIndicatorController: ServiceLocator.shared.userIndicatorController)
flowCoordinator.start() flowCoordinator.start()
retainedState.append(flowCoordinator) retainedState.append(flowCoordinator)
if id == .provisionedAuthenticationFlow {
flowCoordinator.handleAppRoute(.accountProvisioningLink(.init(accountProvider: "example.com", loginHint: nil)), animated: false)
}
return nil return nil
case .appLockFlow, .appLockFlowDisabled: case .appLockFlow, .appLockFlowDisabled:
// The tested coordinator is setup below in the alternate window. // The tested coordinator is setup below in the alternate window.
@@ -16,6 +16,7 @@ enum UITestsScreenIdentifier: String {
case appLockSetupFlowMandatory case appLockSetupFlowMandatory
case appLockSetupFlowUnlock case appLockSetupFlowUnlock
case authenticationFlow case authenticationFlow
case provisionedAuthenticationFlow
case bugReport case bugReport
case createPoll case createPoll
case createRoom case createRoom
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea83e380f7edc4b64fc32bf9efe3e646a55e99674b2c23c0f6133c4068478735
size 99912
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae0ab1353481ccf9413276e74e4ed133e40d1fcdb3a098a9aaa2109ac782edee
size 118205
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23d5b99dffbf13745655e85d5710c600458786ee7006a5cc5595e760c9beecb8
size 532099
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8e07f75e8576371d2937b9595da7b9473261c8ea1deab9ad7aca3aa686ea510d
size 559743
@@ -13,6 +13,9 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase {
// Given the authentication flow. // Given the authentication flow.
let app = Application.launch(.authenticationFlow) let app = Application.launch(.authenticationFlow)
// Check the bug report flow works.
try await verifyReportBugButton(app)
// Splash Screen: Tap get started button // Splash Screen: Tap get started button
app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap() app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap()
@@ -144,4 +147,38 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase {
XCTAssertTrue(wasAlertText.exists, "The web authentication prompt should be shown after selecting a homeserver with OIDC.") XCTAssertTrue(wasAlertText.exists, "The web authentication prompt should be shown after selecting a homeserver with OIDC.")
} }
func testProvisionedLoginWithPassword() async throws {
// Given a provisioned authentication flow.
let app = Application.launch(.provisionedAuthenticationFlow)
// Then the start screen should be configured appropriately.
try await app.assertScreenshot()
// Check the bug report flow works.
try await verifyReportBugButton(app)
// Splash Screen: Tap get started button
app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap()
// Login Screen: Wait for continue button to appear
let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue]
XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0))
// Login Screen: Enter valid credentials
app.textFields[A11yIdentifiers.loginScreen.emailUsername].clearAndTypeText("alice\n", app: app)
app.secureTextFields[A11yIdentifiers.loginScreen.password].clearAndTypeText("12345678", app: app)
// Login Screen: Tap next
app.buttons[A11yIdentifiers.loginScreen.continue].tap()
}
func verifyReportBugButton(_ app: XCUIApplication) async throws {
// Splash Screen: Report a problem button.
app.buttons[A11yIdentifiers.authenticationStartScreen.reportAProblem].tap()
// Bug report: Make sure it exists then cancel.
XCTAssert(app.textFields[A11yIdentifiers.bugReportScreen.report].exists)
app.buttons[A11yIdentifiers.bugReportScreen.cancel].tap()
}
} }
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eea5297a9bded2679b86a8884ddab8e6ebb83ea41d9373759fa38c31cca05e34
size 1371559
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:827b57b732448bab45b2bae268dad31c48b9576e3a9d96a4a382e0ce2e7aa531
size 1210036
@@ -9,6 +9,117 @@ import XCTest
@testable import ElementX @testable import ElementX
@MainActor
class AuthenticationStartScreenViewModelTests: XCTestCase { class AuthenticationStartScreenViewModelTests: XCTestCase {
// Nothing to test, the view model has no mutable state. var clientBuilderFactory: AuthenticationClientBuilderFactoryMock!
var client: ClientSDKMock!
var authenticationService: AuthenticationServiceProtocol!
var viewModel: AuthenticationStartScreenViewModel!
var context: AuthenticationStartScreenViewModel.Context { viewModel.context }
func testInitialState() async throws {
// Given a view model that has no provisioning parameters.
setupViewModel()
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When tapping any of the buttons on the screen
let actions: [(AuthenticationStartScreenViewAction, AuthenticationStartScreenViewModelAction)] = [
(.loginWithQR, .loginWithQR),
(.login, .login),
(.register, .register),
(.reportProblem, .reportProblem)
]
for action in actions {
let deferred = deferFulfillment(viewModel.actions) { $0 == action.1 }
context.send(viewAction: action.0)
try await deferred.fulfill()
// Then the authentication service should not be used yet.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown)
}
}
func testProvisionedOIDCState() async throws {
// Given a view model that has been provisioned with a server that supports OIDC.
setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"))
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When tapping the login button the authentication service should be used and the screen
// should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
context.send(viewAction: .login)
try await deferred.fulfill()
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.prompt, .consent)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.loginHint, "user@company.com")
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .oidc(supportsCreatePrompt: false))
}
func testProvisionedPasswordState() async throws {
// Given a view model that has been provisioned with a server that does not support OIDC.
setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"), supportsOIDC: false)
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When tapping the login button the authentication service should be used and the screen
// should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithPassword }
context.send(viewAction: .login)
try await deferred.fulfill()
// Then a call to configure service should be made.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .password)
}
// MARK: - Helpers
private func setupViewModel(provisioningParameters: AccountProvisioningParameters? = nil, supportsOIDC: Bool = true) {
// Manually create a configuration as the default homeserver address setting is immutable.
client = ClientSDKMock(configuration: .init(oidcLoginURL: supportsOIDC ? "https://account.company.com/authorize" : nil,
supportsOIDCCreatePrompt: false,
supportsPasswordLogin: true))
let configuration = AuthenticationClientBuilderMock.Configuration(homeserverClients: ["company.com": client],
qrCodeClient: client)
clientBuilderFactory = AuthenticationClientBuilderFactoryMock(configuration: .init(builderConfiguration: configuration))
authenticationService = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
clientBuilderFactory: clientBuilderFactory,
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks())
viewModel = AuthenticationStartScreenViewModel(authenticationService: authenticationService,
provisioningParameters: provisioningParameters,
isBugReportServiceEnabled: true,
appSettings: ServiceLocator.shared.settings,
userIndicatorController: UserIndicatorControllerMock())
// Add a fake window in order for the OIDC flow to continue
viewModel.context.send(viewAction: .updateWindow(UIWindow()))
}
}
extension AuthenticationStartScreenViewModelAction {
var isLoginDirectlyWithOIDC: Bool {
switch self {
case .loginDirectlyWithOIDC: true
default: false
}
}
var isLoginDirectlyWithPassword: Bool {
switch self {
case .loginDirectlyWithPassword: true
default: false
}
}
} }
@@ -17,24 +17,6 @@ class LoginScreenViewModelTests: XCTestCase {
var clientBuilderFactory: AuthenticationClientBuilderFactoryMock! var clientBuilderFactory: AuthenticationClientBuilderFactoryMock!
var service: AuthenticationServiceProtocol! var service: AuthenticationServiceProtocol!
private func setupViewModel(homeserverAddress: String = "example.com") async {
clientBuilderFactory = AuthenticationClientBuilderFactoryMock(configuration: .init())
service = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
clientBuilderFactory: clientBuilderFactory,
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks())
guard case .success = await service.configure(for: homeserverAddress, flow: .login) else {
XCTFail("A valid server should be configured for the test.")
return
}
viewModel = LoginScreenViewModel(authenticationService: service,
userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics)
}
func testBasicServer() async { func testBasicServer() async {
// Given the view model configured for a basic server example.com that only supports password authentication. // Given the view model configured for a basic server example.com that only supports password authentication.
await setupViewModel() await setupViewModel()
@@ -160,4 +142,36 @@ class LoginScreenViewModelTests: XCTestCase {
// Then the view state should be updated to show an alert. // Then the view state should be updated to show an alert.
XCTAssertEqual(context.alertInfo?.id, .unknown, "An alert should be shown to the user.") XCTAssertEqual(context.alertInfo?.id, .unknown, "An alert should be shown to the user.")
} }
func testLoginHint() async throws {
await setupViewModel(loginHint: "")
XCTAssertEqual(context.username, "")
await setupViewModel(loginHint: "alice")
XCTAssertEqual(context.username, "alice")
await setupViewModel(loginHint: "mxid:@alice:example.com")
XCTAssertEqual(context.username, "@alice:example.com")
}
// MARK: - Helpers
private func setupViewModel(homeserverAddress: String = "example.com", loginHint: String? = nil) async {
clientBuilderFactory = AuthenticationClientBuilderFactoryMock(configuration: .init())
service = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
clientBuilderFactory: clientBuilderFactory,
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks())
guard case .success = await service.configure(for: homeserverAddress, flow: .login) else {
XCTFail("A valid server should be configured for the test.")
return
}
viewModel = LoginScreenViewModel(authenticationService: service,
loginHint: loginHint,
userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics)
}
} }
@@ -23,7 +23,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
setupViewModel(authenticationFlow: .login) setupViewModel(authenticationFlow: .login)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown) XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen. // When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -32,8 +32,8 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then a call to configure service should be made. // Then a call to configure service should be made.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .consent) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.prompt, .consent)
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
} }
@@ -46,7 +46,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
} }
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen. // When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -55,8 +55,8 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then the configured homeserver should be used and no additional client should be built. // Then the configured homeserver should be used and no additional client should be built.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .consent) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.prompt, .consent)
} }
func testConfirmRegisterWithoutConfiguration() async throws { func testConfirmRegisterWithoutConfiguration() async throws {
@@ -64,7 +64,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
setupViewModel(authenticationFlow: .register) setupViewModel(authenticationFlow: .register)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown) XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen. // When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -73,7 +73,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then a call to configure service should be made. // Then a call to configure service should be made.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429 // The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create) // XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create)
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
@@ -88,7 +88,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
} }
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen. // When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -99,7 +99,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429 // The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create) // XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
} }
func testConfirmPasswordLoginWithoutConfiguration() async throws { func testConfirmPasswordLoginWithoutConfiguration() async throws {
@@ -107,7 +107,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
setupViewModel(authenticationFlow: .login, supportsOIDC: false) setupViewModel(authenticationFlow: .login, supportsOIDC: false)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown) XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen. // When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -116,7 +116,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then a call to configure service should be made, but not for the OIDC URL. // Then a call to configure service should be made, but not for the OIDC URL.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
XCTAssertEqual(service.homeserver.value.loginMode, .password) XCTAssertEqual(service.homeserver.value.loginMode, .password)
} }
@@ -129,7 +129,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
} }
XCTAssertEqual(service.homeserver.value.loginMode, .password) XCTAssertEqual(service.homeserver.value.loginMode, .password)
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen. // When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -138,7 +138,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then the configured homeserver should be used and no additional client should be built, nor a call to get the OIDC URL. // Then the configured homeserver should be used and no additional client should be built, nor a call to get the OIDC URL.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
} }
func testRegistrationNotSupportedAlert() async throws { func testRegistrationNotSupportedAlert() async throws {
+1 -1
View File
@@ -59,7 +59,7 @@ packages:
# Element/Matrix dependencies # Element/Matrix dependencies
MatrixRustSDK: MatrixRustSDK:
url: https://github.com/element-hq/matrix-rust-components-swift url: https://github.com/element-hq/matrix-rust-components-swift
exactVersion: 25.05.07 exactVersion: 25.05.09
# path: ../matrix-rust-sdk # path: ../matrix-rust-sdk
Compound: Compound:
url: https://github.com/element-hq/compound-ios url: https://github.com/element-hq/compound-ios