diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 3a965cf07..e7a79c109 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -608,6 +608,7 @@ 7691233E3572A9173FD96CB3 /* SecureBackupKeyBackupScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E88534A39781D76487D59DF /* SecureBackupKeyBackupScreenViewModelTests.swift */; }; 76C874243A8C440D6CF7B344 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077B01C13BBA2996272C5FB5 /* ProcessInfo.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 */; }; 77693820498ABF3508814D49 /* AppLockServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97F9661ABF08CE002054A2 /* AppLockServiceTests.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 */; }; 9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.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 */; }; 9F11E743EA01482E78A438B0 /* GlobalSearchScreenCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DB19219E6CC4D002E15D48 /* GlobalSearchScreenCell.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 = ""; }; 27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = ""; }; 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenModels.swift; sourceTree = ""; }; - 27DF257F5D968E5DD719583C /* BugReportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportTests.swift; sourceTree = ""; }; 28146817C61423CACCF942F5 /* CallScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenModels.swift; sourceTree = ""; }; 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCacheTests.swift; sourceTree = ""; }; 284FEEB0789B8894E52A7F34 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; @@ -2343,6 +2342,7 @@ C618CA2B6C8758B06C88013C /* CreateRoomCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomCoordinator.swift; sourceTree = ""; }; C687844F60BFF532D49A994C /* AnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTests.swift; sourceTree = ""; }; C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineItem.swift; sourceTree = ""; }; + C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportUITests.swift; sourceTree = ""; }; C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderAvatarImage.swift; sourceTree = ""; }; C715CFE00686DACA59D836EA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/SAS.strings; sourceTree = ""; }; C729D95CB4588D4D9AAC3DFA /* RoomChangePermissionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenModels.swift; sourceTree = ""; }; @@ -4919,7 +4919,7 @@ 19DD166C3625EE426203FA29 /* AppLockSetupTests.swift */, E8495F37D6245AD0CFA1F60B /* AppLockTests.swift */, 37F46CC4FD89ECF4CF26391A /* AuthenticationFlowCoordinatorTests.swift */, - 27DF257F5D968E5DD719583C /* BugReportTests.swift */, + C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */, 2214C32EA81FA9168D923D4C /* CreateRoomScreenTests.swift */, 89BB11A792EF6F70B95B467E /* EncryptionResetTests.swift */, 57AD14D3ADADE8F6A10F9E88 /* EncryptionSettingsTests.swift */, @@ -7836,7 +7836,7 @@ 05E797C4E0048BB487E5C4D6 /* AppLockTests.swift in Sources */, 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */, CB07184D37D5D65327A5A693 /* AuthenticationFlowCoordinatorTests.swift in Sources */, - 9EF72884B74BC39B01640098 /* BugReportTests.swift in Sources */, + 7756C4E90CABE6F14F7920A0 /* BugReportUITests.swift in Sources */, 94D0F36A87E596A93C0C178A /* Bundle.swift in Sources */, 28AB1614E749D1147A2AC6C2 /* CreateRoomScreenTests.swift in Sources */, 8D24671992A1C1753B211221 /* EncryptionResetTests.swift in Sources */, @@ -8657,7 +8657,7 @@ repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 25.05.07; + version = 25.05.09; }; }; 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2a13b1d52..c07a97f50 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -158,8 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/matrix-rust-components-swift", "state" : { - "revision" : "143df312f4dd3902d6013aba110d4d9f4093335d", - "version" : "25.5.7" + "revision" : "cf913e1417825d08a67cd517b9272b7b808193a8", + "version" : "25.5.9" } }, { diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index c5c01f690..412c51ece 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -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? @@ -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 } } diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 4ecdb5f0d..5accb897a 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -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 diff --git a/ElementX/Sources/Application/Navigation/AppRoutes.swift b/ElementX/Sources/Application/Navigation/AppRoutes.swift index 991f7b4e3..2d7408b68 100644 --- a/ElementX/Sources/Application/Navigation/AppRoutes.swift +++ b/ElementX/Sources/Application/Navigation/AppRoutes.swift @@ -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)) + } +} diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index b82ab355b..f39f40f13 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -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) diff --git a/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift index c6322e5bc..a7bc58c5c 100644 --- a/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift @@ -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, diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 1bf5fcba8..ea998c32d 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -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. } } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 294f194c8..d0dda9bd9 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -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. } } diff --git a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift index a73144fbf..02b1b6db1 100644 --- a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift @@ -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() { diff --git a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift index d796825fe..b8bc58cee 100644 --- a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift +++ b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift @@ -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 { diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index 09ca29f85..7ab58719b 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -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" } diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift index 4afba3996..de41ef7ce 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift @@ -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) } diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift index ac8cc810d..35f399a43 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift @@ -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) diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift index c4f815088..37b71afa3 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift @@ -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) diff --git a/ElementX/Sources/Screens/Authentication/OIDCAuthenticationPresenter.swift b/ElementX/Sources/Screens/Authentication/OIDCAuthenticationPresenter.swift index 374f47d95..efbc57582 100644 --- a/ElementX/Sources/Screens/Authentication/OIDCAuthenticationPresenter.swift +++ b/ElementX/Sources/Screens/Authentication/OIDCAuthenticationPresenter.swift @@ -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) { diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift index 1277eb6ee..be664bffb 100644 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift @@ -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: diff --git a/ElementX/Sources/Screens/Authentication/SoftLogoutScreen/SoftLogoutScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/SoftLogoutScreen/SoftLogoutScreenCoordinator.swift index 9c171f2a3..be187cf84 100644 --- a/ElementX/Sources/Screens/Authentication/SoftLogoutScreen/SoftLogoutScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/SoftLogoutScreen/SoftLogoutScreenCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenCoordinator.swift b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenCoordinator.swift index 61e112876..51ce8a152 100644 --- a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenCoordinator.swift +++ b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenModels.swift b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenModels.swift index 9d0c1d810..71855a5d5 100644 --- a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenModels.swift +++ b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenModels.swift @@ -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? +} + +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 } diff --git a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift index b7f2d4c8f..e4ffa70a4 100644 --- a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift +++ b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift @@ -11,28 +11,105 @@ import SwiftUI typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2 class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol { + private let authenticationService: AuthenticationServiceProtocol + private let provisioningParameters: AccountProvisioningParameters? + private let appSettings: AppSettings + private let userIndicatorController: UserIndicatorControllerProtocol + private var actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { 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) + } } diff --git a/ElementX/Sources/Screens/AuthenticationStartScreen/View/AuthenticationStartScreen.swift b/ElementX/Sources/Screens/AuthenticationStartScreen/View/AuthenticationStartScreen.swift index 7014be483..de0d65bcb 100644 --- a/ElementX/Sources/Screens/AuthenticationStartScreen/View/AuthenticationStartScreen.swift +++ b/ElementX/Sources/Screens/AuthenticationStartScreen/View/AuthenticationStartScreen.swift @@ -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()) } } diff --git a/ElementX/Sources/Screens/BugReportScreen/View/BugReportScreen.swift b/ElementX/Sources/Screens/BugReportScreen/View/BugReportScreen.swift index 11d7343b6..71a711949 100644 --- a/ElementX/Sources/Screens/BugReportScreen/View/BugReportScreen.swift +++ b/ElementX/Sources/Screens/BugReportScreen/View/BugReportScreen.swift @@ -122,6 +122,7 @@ struct BugReportScreen: View { Button(L10n.actionCancel) { context.send(viewAction: .cancel) } + .accessibilityIdentifier(A11yIdentifiers.bugReportScreen.cancel) } } diff --git a/ElementX/Sources/Services/Authentication/AuthenticationService.swift b/ElementX/Sources/Services/Authentication/AuthenticationService.swift index e5f1810ac..9aaa08034 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationService.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationService.swift @@ -82,13 +82,14 @@ class AuthenticationService: AuthenticationServiceProtocol { } } - func urlForOIDCLogin() async -> Result { + func urlForOIDCLogin(loginHint: String?) async -> Result { 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)") diff --git a/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift b/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift index 6741f58b7..332274cdd 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift @@ -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 /// Performs login using OIDC for the current homeserver. - func urlForOIDCLogin() async -> Result + func urlForOIDCLogin(loginHint: String?) async -> Result /// 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``. diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 19f8c552b..ddc7e1146 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -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. diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index f171fdb42..79c194ed5 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -16,6 +16,7 @@ enum UITestsScreenIdentifier: String { case appLockSetupFlowMandatory case appLockSetupFlowUnlock case authenticationFlow + case provisionedAuthenticationFlow case bugReport case createPoll case createRoom diff --git a/Enterprise b/Enterprise index c11c3af9a..e004345aa 160000 --- a/Enterprise +++ b/Enterprise @@ -1 +1 @@ -Subproject commit c11c3af9a84d335dbfcc6048c47b46f3af5ba844 +Subproject commit e004345aa506697ca3cc4ee5d842492143d71e5c diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPad-en-GB.png new file mode 100644 index 000000000..42a9904a7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea83e380f7edc4b64fc32bf9efe3e646a55e99674b2c23c0f6133c4068478735 +size 99912 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPad-pseudo.png new file mode 100644 index 000000000..9c9ca2ea7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae0ab1353481ccf9413276e74e4ed133e40d1fcdb3a098a9aaa2109ac782edee +size 118205 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPhone-16-en-GB.png new file mode 100644 index 000000000..2ac5112ae --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23d5b99dffbf13745655e85d5710c600458786ee7006a5cc5595e760c9beecb8 +size 532099 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPhone-16-pseudo.png new file mode 100644 index 000000000..4dd8aa3b4 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Provisioned-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e07f75e8576371d2937b9595da7b9473261c8ea1deab9ad7aca3aa686ea510d +size 559743 diff --git a/UITests/Sources/AuthenticationFlowCoordinatorTests.swift b/UITests/Sources/AuthenticationFlowCoordinatorTests.swift index c98af2b91..0310c6c92 100644 --- a/UITests/Sources/AuthenticationFlowCoordinatorTests.swift +++ b/UITests/Sources/AuthenticationFlowCoordinatorTests.swift @@ -13,6 +13,9 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase { // Given the authentication flow. let app = Application.launch(.authenticationFlow) + // Check the bug report flow works. + try await verifyReportBugButton(app) + // Splash Screen: Tap get started button 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.") } + + 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() + } } diff --git a/UITests/Sources/BugReportTests.swift b/UITests/Sources/BugReportUITests.swift similarity index 100% rename from UITests/Sources/BugReportTests.swift rename to UITests/Sources/BugReportUITests.swift diff --git a/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testProvisionedLoginWithPassword-iPad-18-4-en-GB.png b/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testProvisionedLoginWithPassword-iPad-18-4-en-GB.png new file mode 100644 index 000000000..69860bc3c --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testProvisionedLoginWithPassword-iPad-18-4-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eea5297a9bded2679b86a8884ddab8e6ebb83ea41d9373759fa38c31cca05e34 +size 1371559 diff --git a/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testProvisionedLoginWithPassword-iPhone-18-4-en-GB.png b/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testProvisionedLoginWithPassword-iPhone-18-4-en-GB.png new file mode 100644 index 000000000..e825f8743 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testProvisionedLoginWithPassword-iPhone-18-4-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:827b57b732448bab45b2bae268dad31c48b9576e3a9d96a4a382e0ce2e7aa531 +size 1210036 diff --git a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift index 37d253df1..9e8d11d23 100644 --- a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift +++ b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift @@ -9,6 +9,117 @@ import XCTest @testable import ElementX +@MainActor 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 + } + } } diff --git a/UnitTests/Sources/LoginScreenViewModelTests.swift b/UnitTests/Sources/LoginScreenViewModelTests.swift index ae81aeac0..713b46568 100644 --- a/UnitTests/Sources/LoginScreenViewModelTests.swift +++ b/UnitTests/Sources/LoginScreenViewModelTests.swift @@ -17,24 +17,6 @@ class LoginScreenViewModelTests: XCTestCase { var clientBuilderFactory: AuthenticationClientBuilderFactoryMock! 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 { // Given the view model configured for a basic server example.com that only supports password authentication. await setupViewModel() @@ -160,4 +142,36 @@ class LoginScreenViewModelTests: XCTestCase { // Then the view state should be updated to show an alert. 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) + } } diff --git a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift index 0f62a4c1f..7ae3dea32 100644 --- a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift +++ b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift @@ -23,7 +23,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { setupViewModel(authenticationFlow: .login) XCTAssertEqual(service.homeserver.value.loginMode, .unknown) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } @@ -32,8 +32,8 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { // Then a call to configure service should be made. XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .consent) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.prompt, .consent) XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) } @@ -46,7 +46,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { } XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) // When continuing from the confirmation screen. 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. XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .consent) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.prompt, .consent) } func testConfirmRegisterWithoutConfiguration() async throws { @@ -64,7 +64,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { setupViewModel(authenticationFlow: .register) XCTAssertEqual(service.homeserver.value.loginMode, .unknown) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } @@ -73,7 +73,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { // Then a call to configure service should be made. 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 // XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create) XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) @@ -88,7 +88,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { } XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true)) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) // When continuing from the confirmation screen. let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC } @@ -99,7 +99,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) // The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429 // XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1) } func testConfirmPasswordLoginWithoutConfiguration() async throws { @@ -107,7 +107,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { setupViewModel(authenticationFlow: .login, supportsOIDC: false) XCTAssertEqual(service.homeserver.value.loginMode, .unknown) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) // When continuing from the confirmation screen. 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. XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) XCTAssertEqual(service.homeserver.value.loginMode, .password) } @@ -129,7 +129,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { } XCTAssertEqual(service.homeserver.value.loginMode, .password) XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) // When continuing from the confirmation screen. 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. XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) - XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) } func testRegistrationNotSupportedAlert() async throws { diff --git a/project.yml b/project.yml index 2cab86df4..b84a247e5 100644 --- a/project.yml +++ b/project.yml @@ -59,7 +59,7 @@ packages: # Element/Matrix dependencies MatrixRustSDK: url: https://github.com/element-hq/matrix-rust-components-swift - exactVersion: 25.05.07 + exactVersion: 25.05.09 # path: ../matrix-rust-sdk Compound: url: https://github.com/element-hq/compound-ios