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
@@ -55,7 +55,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
private let appRouteURLParser: AppRouteURLParser
@Consumable private var storedAppRoute: AppRoute?
private var storedAppRoute: AppRoute?
@Consumable private var storedInlineReply: (roomID: String, message: String)?
@Consumable private var storedRoomsToAwait: Set<String>?
@@ -219,6 +219,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
if let route = appRouteURLParser.route(from: url) {
switch route {
case .accountProvisioningLink:
handleAppRoute(route)
case .genericCallLink(let url):
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
@@ -508,17 +510,23 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
appSettings: appSettings,
appHooks: appHooks)
authenticationFlowCoordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
qrCodeLoginService: qrCodeLoginService,
bugReportService: ServiceLocator.shared.bugReportService,
navigationRootCoordinator: navigationRootCoordinator,
appMediator: appMediator,
appSettings: appSettings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
authenticationFlowCoordinator?.delegate = self
let coordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
qrCodeLoginService: qrCodeLoginService,
bugReportService: ServiceLocator.shared.bugReportService,
navigationRootCoordinator: navigationRootCoordinator,
appMediator: appMediator,
appSettings: appSettings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
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 {
@@ -530,7 +538,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
userSession.clientProxy.roomsToAwait = storedRoomsToAwait
}
if let storedAppRoute {
if storedAppRoute?.isAuthenticationRoute == false,
let storedAppRoute = storedAppRoute.take() {
userSessionFlowCoordinator.handleAppRoute(storedAppRoute, animated: false)
}
@@ -796,9 +805,22 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
private func handleAppRoute(_ appRoute: AppRoute) {
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
} else {
var handled = false
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
}
}
@@ -105,6 +105,7 @@ final class AppSettings {
chatBackupDetailsURL: URL,
identityPinningViolationDetailsURL: URL,
elementWebHosts: [String],
accountProvisioningHost: String,
bugReportApplicationID: String,
analyticsTermsURL: URL?,
mapTilerConfiguration: MapTilerConfiguration) {
@@ -121,6 +122,7 @@ final class AppSettings {
self.chatBackupDetailsURL = chatBackupDetailsURL
self.identityPinningViolationDetailsURL = identityPinningViolationDetailsURL
self.elementWebHosts = elementWebHosts
self.accountProvisioningHost = accountProvisioningHost
self.bugReportApplicationID = bugReportApplicationID
self.analyticsTermsURL = analyticsTermsURL
self.mapTilerConfiguration = mapTilerConfiguration
@@ -176,6 +178,8 @@ final class AppSettings {
private(set) var identityPinningViolationDetailsURL: URL = "https://element.io/help#encryption18"
/// 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"]
/// 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))
var appAppearance: AppAppearance
@@ -8,7 +8,12 @@
import Foundation
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.
case roomList
/// A room, shown as the root of the stack (popping any child rooms).
@@ -43,6 +48,25 @@ enum AppRoute: Equatable, Hashable {
case chatBackupSettings
/// An external share request e.g. from the ShareExtension
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 {
@@ -53,6 +77,7 @@ struct AppRouteURLParser {
AppGroupURLParser(),
MatrixPermalinkParser(),
ElementWebURLParser(domains: appSettings.elementWebHosts),
AccountProvisioningURLParser(domain: appSettings.accountProvisioningHost),
ElementCallURLParser()
]
}
@@ -68,6 +93,8 @@ struct AppRouteURLParser {
}
}
// MARK: - URL Parsers
/// Represents a type that can parse a `URL` into an `AppRoute`.
///
/// The following Universal Links are missing parsers.
@@ -76,7 +103,8 @@ protocol URLParser {
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? {
guard let scheme = url.scheme,
scheme == InfoPlistReader.app.appScheme,
@@ -101,7 +129,7 @@ struct AppGroupURLParser: URLParser {
}
/// 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 customSchemeURLQueryParameterName = "url"
@@ -139,7 +167,7 @@ struct ElementCallURLParser: URLParser {
}
}
struct MatrixPermalinkParser: URLParser {
private struct MatrixPermalinkParser: URLParser {
func route(from url: URL) -> AppRoute? {
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 paths = ["room", "user"]
@@ -187,3 +215,21 @@ struct ElementWebURLParser: URLParser {
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.
case startScreen
/// The initial screen with a provisioning link applied.
case provisionedStartScreen
/// The screen used for the whole QR Code flow.
case qrCodeLoginScreen
@@ -55,12 +57,13 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The flow is being started.
case start
/// Modify the flow using the provisioning parameters in the `userInfo`.
case applyProvisioningParameters
/// The user would like to login with a QR code.
case loginWithQR
/// The user would like to login without a QR code.
case loginManually
/// The user would like to register a new account.
case register
/// Show the server confirmation screen.
case confirmServer(AuthenticationFlow)
/// The user encountered a problem.
case reportProblem
@@ -74,19 +77,19 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The user is no longer selecting a server.
case dismissedServerSelection
/// Show the web authentication session for OIDC.
/// Show the web authentication session for OIDC (using the parameters in the `userInfo`).
case continueWithOIDC
/// The web authentication session was aborted.
case cancelledOIDCAuthentication
/// Show the screen to login with password.
case cancelledOIDCAuthentication(previousState: State)
/// Show the screen to login with password (with the optional login hint in the `userInfo`).
case continueWithPassword
/// The password login was aborted.
case cancelledPasswordLogin
case cancelledPasswordLogin(previousState: State)
/// 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
}
@@ -128,18 +131,53 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
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) {
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
private func configureStateMachine() {
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
@@ -151,10 +189,10 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
// 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)
}
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)
}
stateMachine.addRoutes(event: .cancelledServerConfirmation, transitions: [.serverConfirmationScreen => .startScreen])
@@ -167,25 +205,32 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
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 {
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
self?.showLoginScreen()
stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen,
.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
stateMachine.addRoutes(event: .reportProblem, transitions: [.startScreen => .bugReportFlow]) { [weak self] _ in
self?.startBugReportFlow()
stateMachine.addRoutes(event: .reportProblem, transitions: [.startScreen => .bugReportFlow,
.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
@@ -208,9 +253,12 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func showStartScreen() {
let parameters = AuthenticationStartScreenParameters(showCreateAccountButton: appSettings.showCreateAccountButton,
isBugReportServiceEnabled: bugReportService.isEnabled)
private func showStartScreen(fromState: State, applying provisioningParameters: AccountProvisioningParameters? = nil) {
let parameters = AuthenticationStartScreenParameters(authenticationService: authenticationService,
provisioningParameters: provisioningParameters,
isBugReportServiceEnabled: bugReportService.isEnabled,
appSettings: appSettings,
userIndicatorController: userIndicatorController)
let coordinator = AuthenticationStartScreenCoordinator(parameters: parameters)
coordinator.actions
@@ -220,19 +268,26 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .loginWithQR:
stateMachine.tryEvent(.loginWithQR)
case .loginManually:
stateMachine.tryEvent(.loginManually)
case .login:
stateMachine.tryEvent(.confirmServer(.login))
case .register:
stateMachine.tryEvent(.register)
stateMachine.tryEvent(.confirmServer(.register))
case .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)
navigationStackCoordinator.setRootCoordinator(coordinator)
navigationRootCoordinator.setRootCoordinator(navigationStackCoordinator)
if fromState == .initial {
navigationRootCoordinator.setRootCoordinator(navigationStackCoordinator)
}
}
// MARK: - QR Code
@@ -249,7 +304,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .signInManually:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR)
stateMachine.tryEvent(.loginManually)
stateMachine.tryEvent(.confirmServer(.login))
case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil)
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,
oidcRedirectURL: appSettings.oidcRedirectURL,
presentationAnchor: presentationAnchor,
@@ -336,15 +391,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .success(let userSession):
stateMachine.tryEvent(.signedIn, userInfo: userSession)
case .failure:
stateMachine.tryEvent(.cancelledOIDCAuthentication)
stateMachine.tryEvent(.cancelledOIDCAuthentication(previousState: fromState))
// Nothing more to do, the alerts are handled by the presenter.
}
oidcPresenter = nil
}
}
private func showLoginScreen() {
private func showLoginScreen(loginHint: String?, fromState: State) {
let parameters = LoginScreenCoordinatorParameters(authenticationService: authenticationService,
loginHint: loginHint,
userIndicatorController: userIndicatorController,
analytics: analytics)
let coordinator = LoginScreenCoordinator(parameters: parameters)
@@ -364,13 +420,13 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.cancelledPasswordLogin)
self?.stateMachine.tryEvent(.cancelledPasswordLogin(previousState: fromState))
}
}
// MARK: - Bug Report
private func startBugReportFlow() {
private func startBugReportFlow(fromState: State) {
let coordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(navigationStackCoordinator),
userIndicatorController: userIndicatorController,
bugReportService: bugReportService,
@@ -378,7 +434,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actionsPublisher.sink { [weak self] action in
switch action {
case .complete:
self?.stateMachine.tryEvent(.bugReportFlowComplete)
self?.stateMachine.tryEvent(.bugReportFlowComplete(previousState: fromState))
}
}
.store(in: &cancellables)
@@ -77,6 +77,8 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
switch appRoute {
case .accountProvisioningLink:
break // We always ignore this flow when logged in.
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
@@ -214,7 +214,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
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.
}
}
@@ -214,6 +214,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} else {
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
open var urlForOidcOidcConfigurationPromptThrowableError: Error?
var urlForOidcOidcConfigurationPromptUnderlyingCallsCount = 0
open var urlForOidcOidcConfigurationPromptCallsCount: Int {
open var urlForOidcOidcConfigurationPromptLoginHintThrowableError: Error?
var urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount = 0
open var urlForOidcOidcConfigurationPromptLoginHintCallsCount: Int {
get {
if Thread.isMainThread {
return urlForOidcOidcConfigurationPromptUnderlyingCallsCount
return urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = urlForOidcOidcConfigurationPromptUnderlyingCallsCount
returnValue = urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount
}
return returnValue!
@@ -4290,29 +4290,29 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
}
set {
if Thread.isMainThread {
urlForOidcOidcConfigurationPromptUnderlyingCallsCount = newValue
urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
urlForOidcOidcConfigurationPromptUnderlyingCallsCount = newValue
urlForOidcOidcConfigurationPromptLoginHintUnderlyingCallsCount = newValue
}
}
}
}
open var urlForOidcOidcConfigurationPromptCalled: Bool {
return urlForOidcOidcConfigurationPromptCallsCount > 0
open var urlForOidcOidcConfigurationPromptLoginHintCalled: Bool {
return urlForOidcOidcConfigurationPromptLoginHintCallsCount > 0
}
open var urlForOidcOidcConfigurationPromptReceivedArguments: (oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?)?
open var urlForOidcOidcConfigurationPromptReceivedInvocations: [(oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?)] = []
open var urlForOidcOidcConfigurationPromptLoginHintReceivedArguments: (oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?, loginHint: String?)?
open var urlForOidcOidcConfigurationPromptLoginHintReceivedInvocations: [(oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?, loginHint: String?)] = []
var urlForOidcOidcConfigurationPromptUnderlyingReturnValue: OAuthAuthorizationData!
open var urlForOidcOidcConfigurationPromptReturnValue: OAuthAuthorizationData! {
var urlForOidcOidcConfigurationPromptLoginHintUnderlyingReturnValue: OAuthAuthorizationData!
open var urlForOidcOidcConfigurationPromptLoginHintReturnValue: OAuthAuthorizationData! {
get {
if Thread.isMainThread {
return urlForOidcOidcConfigurationPromptUnderlyingReturnValue
return urlForOidcOidcConfigurationPromptLoginHintUnderlyingReturnValue
} else {
var returnValue: OAuthAuthorizationData? = nil
DispatchQueue.main.sync {
returnValue = urlForOidcOidcConfigurationPromptUnderlyingReturnValue
returnValue = urlForOidcOidcConfigurationPromptLoginHintUnderlyingReturnValue
}
return returnValue!
@@ -4320,29 +4320,29 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
}
set {
if Thread.isMainThread {
urlForOidcOidcConfigurationPromptUnderlyingReturnValue = newValue
urlForOidcOidcConfigurationPromptLoginHintUnderlyingReturnValue = newValue
} else {
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 {
if let error = urlForOidcOidcConfigurationPromptThrowableError {
open override func urlForOidc(oidcConfiguration: OidcConfiguration, prompt: OidcPrompt?, loginHint: String?) async throws -> OAuthAuthorizationData {
if let error = urlForOidcOidcConfigurationPromptLoginHintThrowableError {
throw error
}
urlForOidcOidcConfigurationPromptCallsCount += 1
urlForOidcOidcConfigurationPromptReceivedArguments = (oidcConfiguration: oidcConfiguration, prompt: prompt)
urlForOidcOidcConfigurationPromptLoginHintCallsCount += 1
urlForOidcOidcConfigurationPromptLoginHintReceivedArguments = (oidcConfiguration: oidcConfiguration, prompt: prompt, loginHint: loginHint)
DispatchQueue.main.async {
self.urlForOidcOidcConfigurationPromptReceivedInvocations.append((oidcConfiguration: oidcConfiguration, prompt: prompt))
self.urlForOidcOidcConfigurationPromptLoginHintReceivedInvocations.append((oidcConfiguration: oidcConfiguration, prompt: prompt, loginHint: loginHint))
}
if let urlForOidcOidcConfigurationPromptClosure = urlForOidcOidcConfigurationPromptClosure {
return try await urlForOidcOidcConfigurationPromptClosure(oidcConfiguration, prompt)
if let urlForOidcOidcConfigurationPromptLoginHintClosure = urlForOidcOidcConfigurationPromptLoginHintClosure {
return try await urlForOidcOidcConfigurationPromptLoginHintClosure(oidcConfiguration, prompt, loginHint)
} else {
return urlForOidcOidcConfigurationPromptReturnValue
return urlForOidcOidcConfigurationPromptLoginHintReturnValue
}
}
@@ -10658,6 +10658,71 @@ open class QrCodeDataSDKMock: MatrixRustSDK.QrCodeData, @unchecked Sendable {
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 {
init() {
@@ -41,7 +41,7 @@ extension ClientSDKMock {
slidingSyncVersionReturnValue = configuration.slidingSyncVersion
userIdServerNameThrowableError = MockError.generic
serverReturnValue = "https://\(configuration.serverAddress)"
urlForOidcOidcConfigurationPromptReturnValue = OAuthAuthorizationDataSDKMock(configuration: configuration)
urlForOidcOidcConfigurationPromptLoginHintReturnValue = OAuthAuthorizationDataSDKMock(configuration: configuration)
loginUsernamePasswordInitialDeviceNameDeviceIdClosure = { username, password, _, _ in
guard username == configuration.validCredentials.username,
password == configuration.validCredentials.password else {
@@ -78,6 +78,7 @@ enum A11yIdentifiers {
let screenshot = "bug_report-screenshot"
let removeScreenshot = "bug_report-remove_screenshot"
let attachScreenshot = "bug-report-attach_screenshot"
let cancel = "bug_report-cancel"
}
struct ChangeServer {
@@ -121,6 +122,7 @@ enum A11yIdentifiers {
struct AuthenticationStartScreen {
let signIn = "authentication_start-sign_in"
let signInWithQr = "authentication_start-sign_in_with_qr"
let reportAProblem = "authentication_start-report_a_problem"
let hidden = "authentication_start-hidden"
}
@@ -11,6 +11,8 @@ import SwiftUI
struct LoginScreenCoordinatorParameters {
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProtocol
/// An optional hint that can be used to pre-fill the form.
let loginHint: String?
let userIndicatorController: UserIndicatorControllerProtocol
let analytics: AnalyticsService
}
@@ -42,6 +44,7 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = LoginScreenViewModel(authenticationService: parameters.authenticationService,
loginHint: parameters.loginHint,
userIndicatorController: parameters.userIndicatorController,
analytics: parameters.analytics)
}
@@ -21,13 +21,21 @@ class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtoc
}
init(authenticationService: AuthenticationServiceProtocol,
loginHint: String?,
userIndicatorController: UserIndicatorControllerProtocol,
analytics: AnalyticsService) {
self.authenticationService = authenticationService
self.userIndicatorController = userIndicatorController
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)
@@ -161,6 +161,7 @@ struct LoginScreen_Previews: PreviewProvider, TestablePreview {
Task { await authenticationService.configure(for: homeserverAddress, flow: .login) }
let viewModel = LoginScreenViewModel(authenticationService: authenticationService,
loginHint: nil,
userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics)
@@ -15,6 +15,8 @@ class OIDCAuthenticationPresenter: NSObject {
private let presentationAnchor: UIWindow
private let userIndicatorController: UserIndicatorControllerProtocol
private var activeSession: ASWebAuthenticationSession?
init(authenticationService: AuthenticationServiceProtocol,
oidcRedirectURL: URL,
presentationAnchor: UIWindow,
@@ -36,9 +38,12 @@ class OIDCAuthenticationPresenter: NSObject {
session.prefersEphemeralWebBrowserSession = false
session.presentationContextProvider = self
activeSession = session
session.start()
}
activeSession = nil
guard let url else {
// Check for user cancellation to avoid showing an alert in that instance.
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 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
defer { stopLoading() }
switch await authenticationService.urlForOIDCLogin() {
switch await authenticationService.urlForOIDCLogin(loginHint: nil) {
case .success(let oidcData):
actionsSubject.send(.continueWithOIDC(data: oidcData, window: window))
case .failure:
@@ -134,7 +134,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
startLoading()
Task {
switch await authenticationService.urlForOIDCLogin() {
switch await authenticationService.urlForOIDCLogin(loginHint: nil) {
case .failure(let error):
stopLoading()
handleError(error)
@@ -9,8 +9,11 @@ import Combine
import SwiftUI
struct AuthenticationStartScreenParameters {
let showCreateAccountButton: Bool
let authenticationService: AuthenticationServiceProtocol
let provisioningParameters: AccountProvisioningParameters?
let isBugReportServiceEnabled: Bool
let appSettings: AppSettings
let userIndicatorController: UserIndicatorControllerProtocol
}
final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
@@ -23,8 +26,11 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
}
init(parameters: AuthenticationStartScreenParameters) {
viewModel = AuthenticationStartScreenViewModel(showCreateAccountButton: parameters.showCreateAccountButton,
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled)
viewModel = AuthenticationStartScreenViewModel(authenticationService: parameters.authenticationService,
provisioningParameters: parameters.provisioningParameters,
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController)
}
// MARK: - Public
@@ -35,14 +41,19 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .loginManually:
actionsSubject.send(.loginManually)
case .loginWithQR:
actionsSubject.send(.loginWithQR)
case .login:
actionsSubject.send(.login)
case .register:
actionsSubject.send(.register)
case .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)
@@ -11,27 +11,60 @@ import SwiftUI
enum AuthenticationStartScreenCoordinatorAction {
case loginWithQR
case loginManually
case login
case register
case reportProblem
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
case loginDirectlyWithPassword(loginHint: String?)
}
enum AuthenticationStartScreenViewModelAction {
enum AuthenticationStartScreenViewModelAction: Equatable {
case loginWithQR
case loginManually
case login
case register
case reportProblem
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
case loginDirectlyWithPassword(loginHint: String?)
}
struct AuthenticationStartScreenViewState: BindableState {
/// The presentation anchor used for OIDC authentication.
var window: UIWindow?
let serverName: String?
let showCreateAccountButton: Bool
let isQRCodeLoginEnabled: Bool
let isBugReportServiceEnabled: Bool
let showQRCodeLoginButton: 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 {
/// Updates the window used as the OIDC presentation anchor.
case updateWindow(UIWindow)
case loginWithQR
case loginManually
case login
case register
case reportProblem
}
@@ -11,28 +11,105 @@ import SwiftUI
typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2<AuthenticationStartScreenViewState, AuthenticationStartScreenViewAction>
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()
var actions: AnyPublisher<AuthenticationStartScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(showCreateAccountButton: Bool, isBugReportServiceEnabled: Bool) {
super.init(initialViewState: AuthenticationStartScreenViewState(showCreateAccountButton: showCreateAccountButton,
isQRCodeLoginEnabled: !ProcessInfo.processInfo.isiOSAppOnMac,
isBugReportServiceEnabled: isBugReportServiceEnabled))
init(authenticationService: AuthenticationServiceProtocol,
provisioningParameters: AccountProvisioningParameters?,
isBugReportServiceEnabled: Bool,
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) {
switch viewAction {
case .updateWindow(let window):
guard state.window != window else { return }
state.window = window
case .loginWithQR:
actionsSubject.send(.loginWithQR)
case .loginManually:
actionsSubject.send(.loginManually)
case .login:
Task { await login() }
case .register:
actionsSubject.send(.register)
case .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)
.safeAreaInset(edge: .bottom) {
if context.viewState.isBugReportServiceEnabled {
if context.viewState.showReportProblemButton {
Button {
context.send(viewAction: .reportProblem)
} label: {
@@ -45,6 +45,7 @@ struct AuthenticationStartScreen: View {
.padding(.bottom)
}
.frame(width: geometry.size.width)
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.reportAProblem)
}
}
}
@@ -52,6 +53,9 @@ struct AuthenticationStartScreen: View {
.background {
AuthenticationStartScreenBackgroundImage()
}
.introspect(.window, on: .supportedVersions) { window in
context.send(viewAction: .updateWindow(window))
}
}
var content: some View {
@@ -89,7 +93,7 @@ struct AuthenticationStartScreen: View {
/// The main action buttons.
var buttons: some View {
VStack(spacing: 16) {
if context.viewState.isQRCodeLoginEnabled {
if context.viewState.showQRCodeLoginButton {
Button { context.send(viewAction: .loginWithQR) } label: {
Label(L10n.screenOnboardingSignInWithQrCode, icon: \.qrCode)
}
@@ -97,8 +101,8 @@ struct AuthenticationStartScreen: View {
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signInWithQr)
}
Button { context.send(viewAction: .loginManually) } label: {
Text(context.viewState.isQRCodeLoginEnabled ? L10n.screenOnboardingSignInManually : L10n.actionContinue)
Button { context.send(viewAction: .login) } label: {
Text(context.viewState.loginButtonTitle)
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signIn)
@@ -120,16 +124,22 @@ struct AuthenticationStartScreen: View {
struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel()
static let bugReportDisabledViewModel = makeViewModel(isBugReportServiceEnabled: false)
static let provisionedViewModel = makeViewModel(provisionedServerName: "example.com")
static var previews: some View {
AuthenticationStartScreen(context: viewModel.context)
.previewDisplayName("Default")
AuthenticationStartScreen(context: bugReportDisabledViewModel.context)
.previewDisplayName("Bug report disabled")
AuthenticationStartScreen(context: provisionedViewModel.context)
.previewDisplayName("Provisioned")
}
static func makeViewModel(isBugReportServiceEnabled: Bool = true) -> AuthenticationStartScreenViewModel {
AuthenticationStartScreenViewModel(showCreateAccountButton: true,
isBugReportServiceEnabled: isBugReportServiceEnabled)
static func makeViewModel(isBugReportServiceEnabled: Bool = true, provisionedServerName: String? = nil) -> AuthenticationStartScreenViewModel {
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
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) {
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)) }
do {
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// let prompt: OidcPrompt = flow == .register ? .create : .consent
let oidcData = try await client.urlForOidc(oidcConfiguration: appSettings.oidcConfiguration.rustValue,
prompt: .consent)
prompt: .consent,
loginHint: loginHint)
return .success(OIDCAuthorizationDataProxy(underlyingData: oidcData))
} catch {
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.
func configure(for homeserverAddress: String, flow: AuthenticationFlow) async -> Result<Void, AuthenticationServiceError>
/// 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.
func abortOIDCLogin(data: OIDCAuthorizationDataProxy) async
/// Completes an OIDC login that was started using ``urlForOIDCLogin``.
@@ -120,7 +120,7 @@ class MockScreen: Identifiable {
userIndicatorController: ServiceLocator.shared.userIndicatorController))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .authenticationFlow:
case .authenticationFlow, .provisionedAuthenticationFlow:
let flowCoordinator = AuthenticationFlowCoordinator(authenticationService: AuthenticationService.mock,
qrCodeLoginService: QRCodeLoginServiceMock(),
bugReportService: BugReportServiceMock(.init()),
@@ -131,6 +131,11 @@ class MockScreen: Identifiable {
userIndicatorController: ServiceLocator.shared.userIndicatorController)
flowCoordinator.start()
retainedState.append(flowCoordinator)
if id == .provisionedAuthenticationFlow {
flowCoordinator.handleAppRoute(.accountProvisioningLink(.init(accountProvider: "example.com", loginHint: nil)), animated: false)
}
return nil
case .appLockFlow, .appLockFlowDisabled:
// The tested coordinator is setup below in the alternate window.
@@ -16,6 +16,7 @@ enum UITestsScreenIdentifier: String {
case appLockSetupFlowMandatory
case appLockSetupFlowUnlock
case authenticationFlow
case provisionedAuthenticationFlow
case bugReport
case createPoll
case createRoom