diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d38050f33..cf86ddfec 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -9588,6 +9588,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CLASSIC_APP_DEEP_LINK_URL = "element://open"; CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector; CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared"; CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service"; @@ -9661,6 +9662,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CLASSIC_APP_DEEP_LINK_URL = "element://open"; CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector; CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared"; CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service"; diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 6383b4f00..282f3e7c1 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -655,6 +655,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg classicAppManager: classicAppManager, appSettings: appSettings, appHooks: appHooks) + Task { await authenticationService.setupClassicAppAccountState() } let coordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService, bugReportService: bugReportService, diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index d5a913525..f6bffff9c 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -259,11 +259,18 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { } private func showStartScreen(fromState: State, applying provisioningParameters: AccountProvisioningParameters? = nil) { + let mediaProvider = authenticationService.classicAppAccount.map { account in + MediaProvider(mediaLoader: ClassicAppMediaLoader(classicAppAccount: account), + imageCache: .onlyInMemory, + homeserverReachabilityPublisher: appMediator.networkMonitor.reachabilityPublisher) // Close enough approximation + } + let parameters = AuthenticationStartScreenParameters(authenticationService: authenticationService, provisioningParameters: provisioningParameters, isBugReportServiceEnabled: bugReportService.isEnabled, + appMediator: appMediator, appSettings: appSettings, - mediaProvider: nil, // Currently unused. + mediaProvider: mediaProvider, userIndicatorController: userIndicatorController) let coordinator = AuthenticationStartScreenCoordinator(parameters: parameters) @@ -319,8 +326,6 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.confirmServer(.login)) case .signedIn(let userSession): navigationStackCoordinator.setSheetCoordinator(nil) - // Since the qr code login flow includes verification - appSettings.hasRunIdentityConfirmationOnboarding = true DispatchQueue.main.async { self.stateMachine.tryEvent(.signedIn, userInfo: userSession) } diff --git a/ElementX/Sources/Mocks/AuthenticationClientBuilderFactoryMock.swift b/ElementX/Sources/Mocks/AuthenticationClientBuilderFactoryMock.swift index b403f896e..0127360a6 100644 --- a/ElementX/Sources/Mocks/AuthenticationClientBuilderFactoryMock.swift +++ b/ElementX/Sources/Mocks/AuthenticationClientBuilderFactoryMock.swift @@ -14,6 +14,7 @@ extension AuthenticationClientFactoryMock { struct Configuration { var homeserverClients = [ "matrix.org": ClientSDKMock(configuration: .init()), + "https://matrix-client.matrix.org": ClientSDKMock(configuration: .init()), "example.com": ClientSDKMock(configuration: .init(serverAddress: "example.com", homeserverURL: "https://matrix.example.com", slidingSyncVersion: .native, @@ -51,5 +52,12 @@ extension AuthenticationClientFactoryMock { } return client } + + makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure = { address, _, _, _ in + guard let client = configuration.homeserverClients[address] else { + throw ClientBuildError.ServerUnreachable(message: "Not a known homeserver.") + } + return client + } } } diff --git a/ElementX/Sources/Mocks/ClassicAppManagerMock.swift b/ElementX/Sources/Mocks/ClassicAppManagerMock.swift index e02b7e957..97414f774 100644 --- a/ElementX/Sources/Mocks/ClassicAppManagerMock.swift +++ b/ElementX/Sources/Mocks/ClassicAppManagerMock.swift @@ -9,10 +9,18 @@ import Foundation import MatrixRustSDK extension ClassicAppManagerMock { - struct Configuration { } + struct Configuration { + var accounts: [ClassicAppAccount] + var availableSecrets: ClassicAppAccount.AvailableSecrets = .complete + var secretsBundle: SecretsBundleWithUserId? + } convenience init(_ configuration: Configuration) { self.init() + + loadAccountsClosure = { configuration.accounts } + availableSecretsForReturnValue = configuration.availableSecrets + secretsBundleForReturnValue = configuration.secretsBundle } } @@ -22,7 +30,7 @@ extension ClassicAppAccount { displayName: "Alice", avatarURL: nil, serverName: "matrix.org", - homeserverURL: "https://matrix-client.matrix.org/", + homeserverURL: "https://matrix-client.matrix.org", cryptoStoreURL: .cachesDirectory, cryptoStorePassphrase: "1234567890", accessToken: "accessToken") @@ -33,7 +41,7 @@ extension ClassicAppAccount { displayName: "Dan", avatarURL: .mockMXCUserAvatar, serverName: "matrix.org", - homeserverURL: "https://matrix-client.matrix.org/", + homeserverURL: "https://matrix-client.matrix.org", cryptoStoreURL: .cachesDirectory, cryptoStorePassphrase: "1234567890", accessToken: "accessToken") diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 1d8b34311..bcdd53189 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -1946,6 +1946,80 @@ class AuthenticationClientFactoryMock: AuthenticationClientFactoryProtocol, @unc return makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReturnValue } } + //MARK: - makeInMemoryClient + + var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksThrowableError: Error? + var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount = 0 + var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksCallsCount: Int { + get { + if Thread.isMainThread { + return makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount = newValue + } + } + } + } + var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksCalled: Bool { + return makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksCallsCount > 0 + } + var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReceivedArguments: (homeserverAddress: String, clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks)? + var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReceivedInvocations: [(homeserverAddress: String, clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks)] = [] + + var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue: ClientProtocol! + var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReturnValue: ClientProtocol! { + get { + if Thread.isMainThread { + return makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue + } else { + var returnValue: ClientProtocol? = nil + DispatchQueue.main.sync { + returnValue = makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue = newValue + } + } + } + } + var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure: ((String, ClientSessionDelegate, AppSettings, AppHooks) async throws -> ClientProtocol)? + + func makeInMemoryClient(homeserverAddress: String, clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks) async throws -> ClientProtocol { + if let error = makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksThrowableError { + throw error + } + makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksCallsCount += 1 + makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReceivedArguments = (homeserverAddress: homeserverAddress, clientSessionDelegate: clientSessionDelegate, appSettings: appSettings, appHooks: appHooks) + DispatchQueue.main.async { + self.makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReceivedInvocations.append((homeserverAddress: homeserverAddress, clientSessionDelegate: clientSessionDelegate, appSettings: appSettings, appHooks: appHooks)) + } + if let makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure = makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure { + return try await makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure(homeserverAddress, clientSessionDelegate, appSettings, appHooks) + } else { + return makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReturnValue + } + } } class BannedRoomProxyMock: BannedRoomProxyProtocol, @unchecked Sendable { var info: BaseRoomInfoProxyProtocol { @@ -2309,6 +2383,154 @@ class ClassicAppManagerMock: ClassicAppManagerProtocol, @unchecked Sendable { return loadAccountsReturnValue } } + //MARK: - availableSecrets + + var availableSecretsForThrowableError: Error? + var availableSecretsForUnderlyingCallsCount = 0 + var availableSecretsForCallsCount: Int { + get { + if Thread.isMainThread { + return availableSecretsForUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = availableSecretsForUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + availableSecretsForUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + availableSecretsForUnderlyingCallsCount = newValue + } + } + } + } + var availableSecretsForCalled: Bool { + return availableSecretsForCallsCount > 0 + } + var availableSecretsForReceivedAccount: ClassicAppAccount? + var availableSecretsForReceivedInvocations: [ClassicAppAccount] = [] + + var availableSecretsForUnderlyingReturnValue: ClassicAppAccount.AvailableSecrets! + var availableSecretsForReturnValue: ClassicAppAccount.AvailableSecrets! { + get { + if Thread.isMainThread { + return availableSecretsForUnderlyingReturnValue + } else { + var returnValue: ClassicAppAccount.AvailableSecrets? = nil + DispatchQueue.main.sync { + returnValue = availableSecretsForUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + availableSecretsForUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + availableSecretsForUnderlyingReturnValue = newValue + } + } + } + } + var availableSecretsForClosure: ((ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets)? + + func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets { + if let error = availableSecretsForThrowableError { + throw error + } + availableSecretsForCallsCount += 1 + availableSecretsForReceivedAccount = account + DispatchQueue.main.async { + self.availableSecretsForReceivedInvocations.append(account) + } + if let availableSecretsForClosure = availableSecretsForClosure { + return try await availableSecretsForClosure(account) + } else { + return availableSecretsForReturnValue + } + } + //MARK: - secretsBundle + + var secretsBundleForThrowableError: Error? + var secretsBundleForUnderlyingCallsCount = 0 + var secretsBundleForCallsCount: Int { + get { + if Thread.isMainThread { + return secretsBundleForUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = secretsBundleForUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + secretsBundleForUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + secretsBundleForUnderlyingCallsCount = newValue + } + } + } + } + var secretsBundleForCalled: Bool { + return secretsBundleForCallsCount > 0 + } + var secretsBundleForReceivedAccount: ClassicAppAccount? + var secretsBundleForReceivedInvocations: [ClassicAppAccount] = [] + + var secretsBundleForUnderlyingReturnValue: SecretsBundleWithUserId! + var secretsBundleForReturnValue: SecretsBundleWithUserId! { + get { + if Thread.isMainThread { + return secretsBundleForUnderlyingReturnValue + } else { + var returnValue: SecretsBundleWithUserId? = nil + DispatchQueue.main.sync { + returnValue = secretsBundleForUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + secretsBundleForUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + secretsBundleForUnderlyingReturnValue = newValue + } + } + } + } + var secretsBundleForClosure: ((ClassicAppAccount) async throws -> SecretsBundleWithUserId)? + + func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId { + if let error = secretsBundleForThrowableError { + throw error + } + secretsBundleForCallsCount += 1 + secretsBundleForReceivedAccount = account + DispatchQueue.main.async { + self.secretsBundleForReceivedInvocations.append(account) + } + if let secretsBundleForClosure = secretsBundleForClosure { + return try await secretsBundleForClosure(account) + } else { + return secretsBundleForReturnValue + } + } } class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { var actionsPublisher: AnyPublisher { diff --git a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift index 2ebf9649d..3df922465 100644 --- a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift +++ b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift @@ -46,11 +46,16 @@ extension ClientSDKMock { serverReturnValue = "https://\(configuration.serverAddress)" homeserverReturnValue = configuration.homeserverURL urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReturnValue = OAuthAuthorizationDataSDKMock(configuration: configuration) - loginUsernamePasswordInitialDeviceNameDeviceIdClosure = { username, password, _, _ in + loginUsernamePasswordInitialDeviceNameDeviceIdClosure = { [weak self] username, password, _, _ in guard username == configuration.validCredentials.username, password == configuration.validCredentials.password else { throw MockError.generic // use the matrix error } + if username.hasPrefix("@"), username.contains(":") { + self?.userIdReturnValue = username + } else { + self?.userIdReturnValue = "@\(username):\(configuration.serverAddress)" + } } userIdReturnValue = configuration.userID diff --git a/ElementX/Sources/Other/InfoPlistReader.swift b/ElementX/Sources/Other/InfoPlistReader.swift index c5ed0d51b..3c646fac7 100644 --- a/ElementX/Sources/Other/InfoPlistReader.swift +++ b/ElementX/Sources/Other/InfoPlistReader.swift @@ -27,6 +27,7 @@ struct InfoPlistReader { static let classicAppGroupIdentifier = "classicAppGroupIdentifier" static let classicAppKeychainServiceIdentifier = "classicAppKeychainServiceIdentifier" static let classicAppKeychainAccessGroupIdentifier = "classicAppKeychainAccessGroupIdentifier" + static let classicAppDeepLinkURL = "classicAppDeepLinkURL" } private enum Values { @@ -133,6 +134,11 @@ struct InfoPlistReader { infoPlistValue(forKey: Keys.classicAppKeychainAccessGroupIdentifier) } + var classicAppDeepLinkURL: URL? { + let urlString: String? = infoPlistValue(forKey: Keys.classicAppDeepLinkURL) + return urlString.flatMap { URL(string: $0) } + } + // MARK: - Private @_disfavoredOverload // Make sure optional types default to the optional version below. diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift index bd756de0e..eead1c68e 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift @@ -13,6 +13,7 @@ struct AuthenticationStartScreenParameters { let authenticationService: AuthenticationServiceProtocol let provisioningParameters: AccountProvisioningParameters? let isBugReportServiceEnabled: Bool + let appMediator: AppMediatorProtocol let appSettings: AppSettings let mediaProvider: MediaProviderProtocol? let userIndicatorController: UserIndicatorControllerProtocol @@ -41,6 +42,7 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol { viewModel = AuthenticationStartScreenViewModel(authenticationService: parameters.authenticationService, provisioningParameters: parameters.provisioningParameters, isBugReportServiceEnabled: parameters.isBugReportServiceEnabled, + appMediator: parameters.appMediator, appSettings: parameters.appSettings, mediaProvider: parameters.mediaProvider, userIndicatorController: parameters.userIndicatorController) diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift index 91f147a06..f7ad98170 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift @@ -26,6 +26,9 @@ struct AuthenticationStartScreenViewState: BindableState { let showCreateAccountButton: Bool let showQRCodeLoginButton: Bool + enum ClassicAppMode { case welcomeBack(ClassicAppAccount), otherOptions(ClassicAppAccount) } + var classicAppMode: ClassicAppMode? + let hideBrandChrome: Bool var bindings = AuthenticationStartScreenViewStateBindings() @@ -62,4 +65,5 @@ enum AuthenticationStartScreenViewAction { case continueWithClassic(ClassicAppAccount) case otherOptions(ClassicAppAccount) case closeOtherOptions(ClassicAppAccount) + case openClassicApp } diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift index b84072838..0a08d744b 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift @@ -14,6 +14,7 @@ typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2? + private func reloadClassicAppAccount() { + guard case let .welcomeBack(classicAppAccount) = state.classicAppMode else { return } + + reloadClassicAppSecretsTask = Task { [weak self] in + await self?.authenticationService.refreshClassicAppAccountState() + + guard !Task.isCancelled else { return } + + if let availableSecrets = classicAppAccount.state.availableSecrets, availableSecrets != .requiresBackup { + await MainActor.run { self?.state.bindings.showClassicAppBackupInstructions = false } + } + } + } + + // MARK: - User Indicators + private let loadingIndicatorID = "\(AuthenticationStartScreenViewModel.self)-Loading" private func startLoading() { diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppAccountView.swift b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppAccountView.swift index b460bfca9..01d77a15b 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppAccountView.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppAccountView.swift @@ -13,6 +13,10 @@ struct AuthenticationClassicAppAccountView: View { let classicAppAccount: ClassicAppAccount + var isLoadingAccount: Bool { + classicAppAccount.state.isServerSupported == nil || classicAppAccount.state.availableSecrets == nil + } + var body: some View { FullscreenDialog(topPadding: 25, background: .gradient) { VStack(spacing: 38) { @@ -28,6 +32,11 @@ struct AuthenticationClassicAppAccountView: View { } .navigationBarTitleDisplayMode(.inline) .alert(item: $context.alertInfo) + .sheet(isPresented: $context.showClassicAppBackupInstructions) { + AuthenticationClassicAppBackupInstructionsView(classicAppAccount: classicAppAccount) { + context.send(viewAction: .openClassicApp) + } + } .introspect(.window, on: .supportedVersions) { window in context.send(viewAction: .updateWindow(window)) } @@ -73,15 +82,30 @@ struct AuthenticationClassicAppAccountView: View { var buttons: some View { VStack(spacing: 16) { - Button(L10n.actionContinue) { - context.send(viewAction: .continueWithClassic(classicAppAccount)) + if isLoadingAccount { + Button { + context.send(viewAction: .continueWithClassic(classicAppAccount)) + } label: { + Label { + Text(L10n.screenOnboardingCheckingAccount) + } icon: { + ProgressView() + .tint(.compound.iconOnSolidPrimary) + } + } + .buttonStyle(.compound(.primary)) + .disabled(true) + } else { + Button(L10n.actionContinue) { + context.send(viewAction: .continueWithClassic(classicAppAccount)) + } + .buttonStyle(.compound(.primary)) + + Button(L10n.commonOtherOptions) { + context.send(viewAction: .otherOptions(classicAppAccount)) + } + .buttonStyle(.compound(.secondary)) } - .buttonStyle(.compound(.primary)) - - Button(L10n.commonOtherOptions) { - context.send(viewAction: .otherOptions(classicAppAccount)) - } - .buttonStyle(.compound(.secondary)) } } } @@ -102,17 +126,30 @@ private extension ClassicAppAccount { struct AuthenticationClassicAppAccountView_Previews: PreviewProvider { // Not Testable – snapshots generated by main screen. static let viewModel = makeViewModel() + static let classicAppAccount = { + let account = ClassicAppAccount.mockDan + account.state.isServerSupported = true + account.state.availableSecrets = .complete + return account + }() static var previews: some View { + ElementNavigationStack { + AuthenticationClassicAppAccountView(context: viewModel.context, classicAppAccount: classicAppAccount) + } + .previewDisplayName("Ready") + ElementNavigationStack { AuthenticationClassicAppAccountView(context: viewModel.context, classicAppAccount: .mockDan) } + .previewDisplayName("Loading") } static func makeViewModel() -> AuthenticationStartScreenViewModel { AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock, provisioningParameters: nil, isBugReportServiceEnabled: false, + appMediator: AppMediatorMock(), appSettings: ServiceLocator.shared.settings, mediaProvider: MediaProviderMock(configuration: .init()), userIndicatorController: UserIndicatorControllerMock()) diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppBackupInstructionsView.swift b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppBackupInstructionsView.swift index 64a0a28de..b352e2278 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppBackupInstructionsView.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppBackupInstructionsView.swift @@ -9,10 +9,19 @@ import Compound import SwiftUI struct AuthenticationClassicAppBackupInstructionsView: View { - let context: AuthenticationStartScreenViewModel.Context - @Environment(\.dismiss) private var dismiss + let classicAppAccount: ClassicAppAccount + let openClassicAppAction: () -> Void + + private var isRefreshingSecrets: Bool { + classicAppAccount.state.availableSecrets == nil + } + + private var buttonTitle: String { + isRefreshingSecrets ? L10n.screenOnboardingCheckingAccount : L10n.screenMissingKeyBackupOpenElementClassic + } + var body: some View { ElementNavigationStack { FullscreenDialog(topPadding: 24, horizontalPadding: 24) { @@ -32,6 +41,7 @@ struct AuthenticationClassicAppBackupInstructionsView: View { TitleAndIcon(title: L10n.screenMissingKeyBackupTitle(InfoPlistReader.main.bundleDisplayName), icon: \.keySolid, iconStyle: .default) + .frame(maxWidth: .infinity) SFNumberedListView(items: [ AttributedString(L10n.screenMissingKeyBackupStep1), @@ -44,10 +54,18 @@ struct AuthenticationClassicAppBackupInstructionsView: View { } var buttons: some View { - Button(L10n.screenMissingKeyBackupOpenElementClassic) { - UIApplication.shared.open("element://open") + Button(action: openClassicAppAction) { + Label { + Text(buttonTitle) + } icon: { + if isRefreshingSecrets { + ProgressView() + .tint(.compound.iconOnSolidPrimary) + } + } } .buttonStyle(.compound(.primary)) + .disabled(isRefreshingSecrets) } var toolbar: some ToolbarContent { @@ -58,18 +76,17 @@ struct AuthenticationClassicAppBackupInstructionsView: View { } struct AuthenticationClassicAppBackupInstructionsView_Previews: PreviewProvider, TestablePreview { - static let viewModel = makeViewModel() + static let loadedAccount = { + let account = ClassicAppAccount.mockDan + account.state.availableSecrets = .requiresBackup + return account + }() static var previews: some View { - AuthenticationClassicAppBackupInstructionsView(context: viewModel.context) - } - - static func makeViewModel() -> AuthenticationStartScreenViewModel { - AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock, - provisioningParameters: nil, - isBugReportServiceEnabled: false, - appSettings: ServiceLocator.shared.settings, - mediaProvider: MediaProviderMock(configuration: .init()), - userIndicatorController: UserIndicatorControllerMock()) + AuthenticationClassicAppBackupInstructionsView(classicAppAccount: loadedAccount) { } + .previewDisplayName("Initial") + + AuthenticationClassicAppBackupInstructionsView(classicAppAccount: .mockAlice) { } + .previewDisplayName("Refreshing") } } diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift index 440c2f851..a598e54fa 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift @@ -16,6 +16,15 @@ struct AuthenticationStartScreen: View { @Bindable var context: AuthenticationStartScreenViewModel.Context var body: some View { + if case let .welcomeBack(classicAppAccount) = context.viewState.classicAppMode, + classicAppAccount.state.isServerSupported != false { + AuthenticationClassicAppAccountView(context: context, classicAppAccount: classicAppAccount) + } else { + standardContent + } + } + + var standardContent: some View { // This view uses a GeometryReader instead of FullscreenDialog so its content takes the full // height available (after taking the buttons out of the equation) in order for the logo // and title to appear vertically centred and equally spaced within this content area. @@ -41,14 +50,12 @@ struct AuthenticationStartScreen: View { } .scrollBounceBehavior(.basedOnSize) } - .navigationBarHidden(true) .background { AuthenticationStartScreenBackgroundImage() } + .navigationBarHidden(context.viewState.classicAppMode == nil) + .toolbar { toolbar } .alert(item: $context.alertInfo) - .sheet(isPresented: $context.showClassicAppBackupInstructions) { - AuthenticationClassicAppBackupInstructionsView(context: context) - } .introspect(.window, on: .supportedVersions) { window in context.send(viewAction: .updateWindow(window)) } @@ -132,6 +139,17 @@ struct AuthenticationStartScreen: View { let shortVersionString = ProcessInfo.isRunningTests ? "0.0.0" : InfoPlistReader.main.bundleShortVersionString return Text(L10n.screenOnboardingAppVersion(shortVersionString)) } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .primaryAction) { + if case let .otherOptions(classicAppAccount) = context.viewState.classicAppMode { + ToolbarButton(role: .close) { + context.send(viewAction: .closeOtherOptions(classicAppAccount)) + } + } + } + } } // MARK: - Previews @@ -139,20 +157,32 @@ struct AuthenticationStartScreen: View { struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview { static let viewModel = makeViewModel() static let provisionedViewModel = makeViewModel(provisionedServerName: "example.com") + static let classicAppViewModel = makeViewModel(hasClassicAppAccount: true) static var previews: some View { AuthenticationStartScreen(context: viewModel.context) .previewDisplayName("Default") AuthenticationStartScreen(context: provisionedViewModel.context) .previewDisplayName("Provisioned") + + ElementNavigationStack { + AuthenticationStartScreen(context: classicAppViewModel.context) + } + .previewDisplayName("Classic App") } - static func makeViewModel(provisionedServerName: String? = nil) -> AuthenticationStartScreenViewModel { - AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock, - provisioningParameters: provisionedServerName.map { .init(accountProvider: $0, loginHint: nil) }, - isBugReportServiceEnabled: true, - appSettings: ServiceLocator.shared.settings, - mediaProvider: MediaProviderMock(configuration: .init()), - userIndicatorController: UserIndicatorControllerMock()) + static func makeViewModel(provisionedServerName: String? = nil, hasClassicAppAccount: Bool = false) -> AuthenticationStartScreenViewModel { + let classicAppAccount = ClassicAppAccount.mockDan + classicAppAccount.state.isServerSupported = true + classicAppAccount.state.availableSecrets = .complete + let classicAppManager: ClassicAppManagerMock? = hasClassicAppAccount ? .init(.init(accounts: [classicAppAccount])) : nil + + return AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock(classicAppManager: classicAppManager), + provisioningParameters: provisionedServerName.map { .init(accountProvider: $0, loginHint: nil) }, + isBugReportServiceEnabled: true, + appMediator: AppMediatorMock(), + appSettings: ServiceLocator.shared.settings, + mediaProvider: MediaProviderMock(configuration: .init()), + userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/ElementX/Sources/Services/Authentication/AuthenticationClientFactory.swift b/ElementX/Sources/Services/Authentication/AuthenticationClientFactory.swift index 314e94dc8..0716dcd2c 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationClientFactory.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationClientFactory.swift @@ -17,6 +17,11 @@ protocol AuthenticationClientFactoryProtocol { clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks) async throws -> ClientProtocol + + func makeInMemoryClient(homeserverAddress: String, + clientSessionDelegate: ClientSessionDelegate, + appSettings: AppSettings, + appHooks: AppHooks) async throws -> ClientProtocol } /// A wrapper around `ClientBuilder` to allow for mocked clients to be injected into authentication tests. @@ -40,4 +45,21 @@ struct AuthenticationClientFactory: AuthenticationClientFactoryProtocol { .serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress) .build() } + + func makeInMemoryClient(homeserverAddress: String, + clientSessionDelegate: ClientSessionDelegate, + appSettings: AppSettings, + appHooks: AppHooks) async throws -> ClientProtocol { + try await ClientBuilder + .baseBuilder(httpProxy: appSettings.websiteURL.globalProxy, + slidingSync: .discover, + sessionDelegate: clientSessionDelegate, + appHooks: appHooks, + enableOnlySignedDeviceIsolationMode: appSettings.enableOnlySignedDeviceIsolationMode, + enableKeyShareOnInvite: appSettings.enableKeyShareOnInvite, + threadsEnabled: appSettings.threadsEnabled) + .inMemoryStore() + .serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress) + .build() + } } diff --git a/ElementX/Sources/Services/Authentication/AuthenticationService.swift b/ElementX/Sources/Services/Authentication/AuthenticationService.swift index 7318f921e..a3b521754 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationService.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationService.swift @@ -28,6 +28,8 @@ class AuthenticationService: AuthenticationServiceProtocol { private(set) var flow: AuthenticationFlow + let classicAppAccount: ClassicAppAccount? + init(userSessionStore: UserSessionStoreProtocol, encryptionKeyProvider: EncryptionKeyProviderProtocol, classicAppManager: ClassicAppManagerProtocol?, @@ -45,14 +47,15 @@ class AuthenticationService: AuthenticationServiceProtocol { do { if let classicAppManager { - // Just let the app manager log the detected account for now. - _ = try classicAppManager.loadAccounts() + classicAppAccount = try classicAppManager.loadAccounts().first } else { MXLog.info("Classic App not configured, skipping loadAccounts.") + classicAppAccount = nil } } catch { - // This should show an alert: "We have detected an older version of Element Classic, but no bueno!" + // No need to alert the user of the failure, just log it. They can still sign in manually. MXLog.error("Failed loading accounts from the Classic app: \(error)") + classicAppAccount = nil } // When updating these, don't forget to update the reset method too. @@ -129,6 +132,7 @@ class AuthenticationService: AuthenticationServiceProtocol { guard let client else { return .failure(.failedLoggingIn) } do { try await client.loginWithOidcCallback(callbackUrl: callbackURL.absoluteString) + await verifyClientIfPossible(client: client) return await userSession(for: client) } catch OidcError.Cancelled { return .failure(.oidcError(.userCancellation)) @@ -150,6 +154,8 @@ class AuthenticationService: AuthenticationServiceProtocol { return .failure(.sessionTokenRefreshNotSupported) } + await verifyClientIfPossible(client: client) + return await userSession(for: client) } catch let ClientError.MatrixApi(errorKind, _, _, _) { MXLog.error("Failed logging in with error kind: \(errorKind)") @@ -203,6 +209,9 @@ class AuthenticationService: AuthenticationServiceProtocol { let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue) try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener) + // Since the QR code login flow includes verification. + appSettings.hasRunIdentityConfirmationOnboarding = true + switch await userSession(for: client) { case .success(let userSession): progressSubject.send(.signedIn(userSession)) @@ -260,6 +269,86 @@ class AuthenticationService: AuthenticationServiceProtocol { return .failure(.failedLoggingIn) } } + + // MARK: - Classic App + + /// Populates the Classic app account's state by checking whether the account's homeserver is supported + /// (has Sliding Sync and OIDC or password login) and whether all of the required secrets are available. + func setupClassicAppAccountState() async { + guard let classicAppAccount, classicAppAccount.state.isServerSupported == nil else { return } + MXLog.info("Checking Classic app account: \(classicAppAccount)") + + do { + let client = try await clientFactory.makeInMemoryClient(homeserverAddress: classicAppAccount.homeserverURL.absoluteString, + clientSessionDelegate: userSessionStore.clientSessionDelegate, + appSettings: appSettings, + appHooks: appHooks) + let loginDetails = await client.homeserverLoginDetails() + let isServerSupported = loginDetails.supportsOidcLogin() || loginDetails.supportsPasswordLogin() + MXLog.info("Classic app homeserver supported: \(isServerSupported)") + classicAppAccount.state.isServerSupported = isServerSupported + + await refreshClassicAppAccountState() + } catch { + MXLog.info("Classic app account support check failed: \(error)") + classicAppAccount.state.isServerSupported = false + } + } + + /// Checks which encryption secrets are currently available from the Classic app and updates the account's state accordingly. We will handle the + /// Classic account differently, depending on which secrets are available: + /// - When they're `.complete` (the session is verified and has a key backup) we can automatically verify the account once signed in. + /// - When they're `.requiresBackup` we prompt the user to enable a key backup before signing in so that their messages can be decrypted. + /// - When they're `.unavailable` (an unverified session without secret storage) we simply show the Classic account to help the user sign in + /// faster but they will need to reset their identity and verify the Classic account themselves. + /// + /// This should be called whenever the user has potentially updated their secrets in the Classic app. + func refreshClassicAppAccountState() async { + guard let classicAppManager, let classicAppAccount, classicAppAccount.state.isServerSupported != nil else { return } + + classicAppAccount.state.availableSecrets = nil + + do { + let availableSecrets = try await classicAppManager.availableSecrets(for: classicAppAccount) + guard !Task.isCancelled else { return } + MXLog.info("Classic app secrets: \(availableSecrets)") + classicAppAccount.state.availableSecrets = availableSecrets + } catch { + MXLog.info("Failed to refresh Classic app account secrets: \(error)") + classicAppAccount.state.availableSecrets = .unavailable + } + } + + /// Imports the Classic app's encryption secrets into the signed-in client, automatically verifying the session. This will no-op if + /// the user signed in with a different account or when the Classic app doesn't have a complete set of secrets (meaning either + /// key backup is disabled or the session hasn't been verified). + private func verifyClientIfPossible(client: ClientProtocol) async { + guard let classicAppManager, let classicAppAccount else { return } + + // Technically the SDK makes sure the secrets are for the correct account, but as + // we want to verify the classic account regardless which flow was used, it seems + // sane to avoid loading the secrets when we know that they're not relevant. + guard classicAppAccount.userID == (try? client.userId()) else { return } + + guard classicAppAccount.state.availableSecrets == .complete else { + MXLog.info("The matching Classic app account is missing secrets, ignoring.") + return + } + + MXLog.info("Found matching Classic app account, importing secrets.") + + do { + let secrets = try await classicAppManager.secretsBundle(for: classicAppAccount) + try await client.encryption().importSecretsBundle(secretsBundle: secrets) + + MXLog.info("Classic app account secrets imported.") + + // Importing the secrets automatically verifies the session. + appSettings.hasRunIdentityConfirmationOnboarding = true + } catch { + MXLog.error("Failed to import secrets for Classic app account: \(error)") + } + } } private extension HumanQrLoginError { @@ -291,9 +380,13 @@ private extension HumanQrLoginError { extension AuthenticationService { static var mock: AuthenticationService { + mock(classicAppManager: nil) + } + + static func mock(classicAppManager: ClassicAppManagerProtocol?) -> AuthenticationService { AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), encryptionKeyProvider: EncryptionKeyProvider(), - classicAppManager: nil, + classicAppManager: classicAppManager, clientFactory: AuthenticationClientFactoryMock(configuration: .init()), appSettings: ServiceLocator.shared.settings, appHooks: AppHooks()) diff --git a/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift b/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift index bc21f5467..ce1e1ec3c 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift @@ -57,6 +57,17 @@ protocol AuthenticationServiceProtocol: QRCodeLoginServiceProtocol { /// Resets the current configuration requiring `configure(for:flow:)` to be called again. func reset() + + // MARK: - Classic App + + /// Account details discovered from the Classic app that is used for automatic verification when the same account is authenticated. + var classicAppAccount: ClassicAppAccount? { get } + /// Populates the Classic app account's state by checking if the homeserver is supported and which secrets are available. + /// + /// **Note:** This is no longer automatic purely for testing purposes. It needs to have been called before using ``classicAppAccount``. + func setupClassicAppAccountState() async + /// This can be called whenever the user has potentially updated their secrets in the Classic app. + func refreshClassicAppAccountState() async } // MARK: - OIDC diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift index b9b965b04..2c1fd7237 100644 --- a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift @@ -6,6 +6,7 @@ // import Foundation +import Observation struct ClassicAppAccount: Equatable, CustomStringConvertible { let userID: String @@ -24,6 +25,27 @@ struct ClassicAppAccount: Equatable, CustomStringConvertible { var description: String { "ClassicAppAccount(userID: \(userID), homeserverURL: \(homeserverURL))" } + + enum AvailableSecrets { case complete, requiresBackup, unavailable } + + @Observable + class State: Equatable { + static func == (lhs: State, rhs: State) -> Bool { + lhs.isServerSupported == rhs.isServerSupported && lhs.availableSecrets == rhs.availableSecrets + } + + /// Whether or not the account's server is supported by Element X (or `nil` whilst determining support). + /// + /// The account will be hidden when this value is `false`. + var isServerSupported: Bool? + /// Information about the secrets available from Element X (or `nil` whilst determining availability). + /// + /// See ``AuthenticationService.refreshClassicAppAccountState`` for details about how + /// this property's value affects the authentication flow. + var availableSecrets: AvailableSecrets? + } + + let state = State() } // MARK: NSCoding Types diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift index 0cbb9381a..341bb451f 100644 --- a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift @@ -11,13 +11,19 @@ import MatrixRustSDK // sourcery: AutoMockable protocol ClassicAppManagerProtocol { + /// Loads all of the accounts found in the Classic app's file store. func loadAccounts() throws -> [ClassicAppAccount] + /// Determines which secrets will be available when loading the secrets bundle for a given account. + func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets + /// Loads the secrets bundle for a given account. + func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId } enum ClassicAppManagerError: Error { case invalidAppGroupIdentifier(String) case missingAccountKeys case missingCryptoStorePassphrase + case missingKeyBackupVersion } /// Reads accounts from Element Classic's shared storage. @@ -45,7 +51,6 @@ final class ClassicAppManager: ClassicAppManagerProtocol { keychain = Keychain(service: classicAppKeychainServiceIdentifier, accessGroup: classicAppKeychainAccessGroupIdentifier) } - /// Loads all of the active accounts from the Classic app. func loadAccounts() throws -> [ClassicAppAccount] { // The account data is stored in the App Group container. guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: classicAppGroupIdentifier) else { @@ -69,4 +74,41 @@ final class ClassicAppManager: ClassicAppManagerProtocol { accountManager.loadAccounts() return accountManager.accounts } + + func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets { + switch try await databaseContainsSecretsBundle(databasePath: account.cryptoStoreURL.path(percentEncoded: false), + passphrase: account.cryptoStorePassphrase, + backupInfo: keyBackupVersion(for: account)) { + case .complete: .complete + case .none: .unavailable + case .withoutBackup, .unusedBackup: .requiresBackup + } + } + + func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId { + guard let keyBackupVersion = try await keyBackupVersion(for: account) else { + throw ClassicAppManagerError.missingKeyBackupVersion + } + + return try await SecretsBundleWithUserId.fromDatabase(databasePath: account.cryptoStoreURL.path(percentEncoded: false), + passphrase: account.cryptoStorePassphrase, + backupInfo: keyBackupVersion) + } + + /// Fetches the current key backup version from the homeserver. This is needed to determine whether + /// the backup key from the crypto store is for the backup currently being used by the account. + private func keyBackupVersion(for account: ClassicAppAccount) async throws -> String? { + let url = account.homeserverURL.appending(path: "_matrix/client/v3/room_keys/version") + var request = URLRequest(url: url) + request.setValue("Bearer \(account.accessToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode != 200 { + return nil + } + + return String(data: data, encoding: .utf8) + } } diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index 804e002dd..18bf29057 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -124,6 +124,8 @@ $(APP_GROUP_IDENTIFIER) baseBundleIdentifier $(BASE_BUNDLE_IDENTIFIER) + classicAppDeepLinkURL + $(CLASSIC_APP_DEEP_LINK_URL) classicAppGroupIdentifier $(CLASSIC_APP_GROUP_IDENTIFIER) classicAppKeychainAccessGroupIdentifier diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index a85944092..6f0abbf71 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -82,6 +82,7 @@ targets: classicAppGroupIdentifier: $(CLASSIC_APP_GROUP_IDENTIFIER) classicAppKeychainServiceIdentifier: $(CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER) classicAppKeychainAccessGroupIdentifier: $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + classicAppDeepLinkURL: $(CLASSIC_APP_DEEP_LINK_URL) productionAppName: $(PRODUCTION_APP_NAME) ITSAppUsesNonExemptEncryption: false NSUserActivityTypes: [ diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPad-en-GB.png new file mode 100644 index 000000000..0db59db94 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19e12d5dbc56168733ce4956d8b420fe9d2ce72d404ce2ec56323985ab2c226a +size 145006 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPad-pseudo.png new file mode 100644 index 000000000..95b8744b2 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0843207a3645299effecf77d86caa8cf99a801e253c9ca49f789c014965a4e3c +size 181159 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPhone-en-GB.png new file mode 100644 index 000000000..a5772c81a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08cf95b60f99486657ffc4b96c557f458c9c843bbe2d0265dbd4b40c3a232662 +size 96042 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPhone-pseudo.png new file mode 100644 index 000000000..1fe39b0a5 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Initial-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79301b22af27679df686a607d6b1f8011b6ebea37f97040e900794372adaa218 +size 141913 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPad-en-GB.png new file mode 100644 index 000000000..e881218d3 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6822abf96bfd69a5bfea7c9e3b32a632dccd1d1434e3e851eb65804a0d359440 +size 144361 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPad-pseudo.png new file mode 100644 index 000000000..9bd407440 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3da597921389f7f4c271eb691945f13c09476454bc491161d28022adec4b31c0 +size 179232 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPhone-en-GB.png new file mode 100644 index 000000000..42dabc7f4 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:914849110d107a01c557f1d6289ace6eb5dc8b9831215cb486ae9401495b2e36 +size 95480 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPhone-pseudo.png new file mode 100644 index 000000000..8839bf47c --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.Refreshing-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c771e99c1848dcc57f952e2d8426da9adeb97cb97fa8797b3e15f7e137ee08f +size 140035 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPad-en-GB-0.png deleted file mode 100644 index c787f220f..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPad-en-GB-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1d033dd37f0c28c175f1181404ea9855963ffe830394dabfbbaf4877f36e52a4 -size 145715 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPad-pseudo-0.png deleted file mode 100644 index 5374f3a72..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPad-pseudo-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ffa8567fccd143f8dfe238c96d263717c99a6448953d3d10247b1012c751a956 -size 181186 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPhone-en-GB-0.png deleted file mode 100644 index d575939a3..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPhone-en-GB-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c20b19a79a4287aca05cc58d51e09232861361a5d9f29606f8a7eb912e78d2ee -size 96938 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPhone-pseudo-0.png deleted file mode 100644 index 18d11122b..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationClassicAppBackupInstructionsView.iPhone-pseudo-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef97e734216be0e6ef36d0f0106c71728ea5a0cf57ce2164ad09157953dc22e2 -size 141919 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPad-en-GB.png new file mode 100644 index 000000000..57b7c0706 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01f374840a1098f1870230fa9784cf56b250858b7341d575643af4f178932e7c +size 645596 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPad-pseudo.png new file mode 100644 index 000000000..e52ed2ff7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af206923909523cd2d61b7af9d9e66a333a688c758131550d37357a740ceccac +size 651937 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPhone-en-GB.png new file mode 100644 index 000000000..da8658437 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35bbd58addd849a6e088928b15496347e3e05d5961f089485bbbdd2623ed42d0 +size 432550 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPhone-pseudo.png new file mode 100644 index 000000000..d043ee751 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/authenticationStartScreen.Classic-App-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fb2d1117f8af713e510b37d136f9b9c73c2d6eb7fc00ab3726b1b2e84e2e208 +size 440193 diff --git a/UnitTests/Sources/AuthenticationServiceTests.swift b/UnitTests/Sources/AuthenticationServiceTests.swift index b96366c05..f9a02ce2d 100644 --- a/UnitTests/Sources/AuthenticationServiceTests.swift +++ b/UnitTests/Sources/AuthenticationServiceTests.swift @@ -14,13 +14,14 @@ import Testing @MainActor struct AuthenticationServiceTests { var client: ClientSDKMock! + var encryption: EncryptionSDKMock! var userSessionStore: UserSessionStoreMock! var encryptionKeyProvider: MockEncryptionKeyProvider! var service: AuthenticationService! @Test - mutating func passwordLogin() async { - setup(serverAddress: "example.com") + mutating func passwordLogin() async throws { + try await setup(serverAddress: "example.com") switch await service.configure(for: "example.com", flow: .login) { case .success: @@ -44,30 +45,20 @@ struct AuthenticationServiceTests { } @Test - mutating func configureLoginWithOIDC() async { - setup() + mutating func configureLoginWithOIDC() async throws { + try await setup() - switch await service.configure(for: "matrix.org", flow: .login) { - case .success: - break - case .failure(let error): - Issue.record("Unexpected failure: \(error)") - } + try await service.configure(for: "matrix.org", flow: .login).get() #expect(service.flow == .login) #expect(service.homeserver.value == .mockMatrixDotOrg) } @Test - mutating func configureRegisterWithOIDC() async { - setup() + mutating func configureRegisterWithOIDC() async throws { + try await setup() - switch await service.configure(for: "matrix.org", flow: .register) { - case .success: - break - case .failure(let error): - Issue.record("Unexpected failure: \(error)") - } + try await service.configure(for: "matrix.org", flow: .register).get() #expect(service.flow == .register) #expect(service.homeserver.value == .mockMatrixDotOrg) @@ -75,37 +66,101 @@ struct AuthenticationServiceTests { @Test @MainActor - mutating func configureRegisterNoSupport() async { + mutating func configureRegisterNoSupport() async throws { let homeserverAddress = "example.com" - setup(serverAddress: homeserverAddress) + try await setup(serverAddress: homeserverAddress) - switch await service.configure(for: homeserverAddress, flow: .register) { - case .success: - Issue.record("Configuration should have failed") - case .failure(let error): - #expect(error == .registrationNotSupported) + try await #require(throws: AuthenticationServiceError.registrationNotSupported) { + try await service.configure(for: homeserverAddress, flow: .register).get() } #expect(service.flow == .login) #expect(service.homeserver.value == .init(address: "matrix.org", loginMode: .unknown)) } + @Test + @MainActor + mutating func classicAppAccountSecretsBundleIsUsed() async throws { + // Given an authentication service with an Element Classic account for Alice. + try await setup(classicAppAccounts: [.mockAlice]) + try await service.configure(for: "matrix.org", flow: .login).get() + #expect(service.flow == .login) + #expect(service.classicAppAccount?.state.availableSecrets == .complete) + + // When logging in as Alice. + _ = try await service.login(username: "alice", password: "12345678", initialDeviceName: nil, deviceID: nil).get() + #expect(client.loginUsernamePasswordInitialDeviceNameDeviceIdCallsCount == 1) + + // Then Alice's secrets from Element Classic should be imported. + #expect(encryption.importSecretsBundleSecretsBundleCalled) + } + + @Test + @MainActor + mutating func classicAppAccountSecretsBundleIsIgnoredWhenUnavailable() async throws { + // Given an authentication service with an Element Classic account for Alice + // which isn't configured with any available secrets. + try await setup(classicAppAccounts: [.mockAlice], availableSecrets: .unavailable) + try await service.configure(for: "matrix.org", flow: .login).get() + #expect(service.flow == .login) + #expect(service.classicAppAccount?.state.availableSecrets == .unavailable) + + // When logging in as Alice. + _ = try await service.login(username: "alice", password: "12345678", initialDeviceName: nil, deviceID: nil).get() + #expect(client.loginUsernamePasswordInitialDeviceNameDeviceIdCallsCount == 1) + + // Then an attempt to import Alice's secrets from Element Classic must not be made. + #expect(!encryption.importSecretsBundleSecretsBundleCalled) + } + + @Test + @MainActor + mutating func classicAppAccountSecretsBundleIsIgnoredForDifferentUser() async throws { + // Given an authentication service with an Element Classic account for Dan. + try await setup(classicAppAccounts: [.mockDan]) + try await service.configure(for: "matrix.org", flow: .login).get() + #expect(service.flow == .login) + #expect(service.classicAppAccount?.state.availableSecrets == .complete) + + // When logging in as Alice + _ = try await service.login(username: "alice", password: "12345678", initialDeviceName: nil, deviceID: nil).get() + #expect(client.loginUsernamePasswordInitialDeviceNameDeviceIdCallsCount == 1) + + // Then Dan's secrets from Element Calssic should not be imported into Alice's client. + #expect(!encryption.importSecretsBundleSecretsBundleCalled) + } + // MARK: - Helpers - private mutating func setup(serverAddress: String = "matrix.org") { + private mutating func setup(serverAddress: String = "matrix.org", + classicAppAccounts: [ClassicAppAccount] = [], + availableSecrets: ClassicAppAccount.AvailableSecrets = .complete) async throws { let configuration: AuthenticationClientFactoryMock.Configuration = .init() let clientFactory = AuthenticationClientFactoryMock(configuration: configuration) client = configuration.homeserverClients[serverAddress] + encryption = EncryptionSDKMock() + client.encryptionReturnValue = encryption + userSessionStore = UserSessionStoreMock(configuration: .init()) encryptionKeyProvider = MockEncryptionKeyProvider() + let classicAppManager = ClassicAppManagerMock(.init(accounts: classicAppAccounts, + availableSecrets: availableSecrets, + secretsBundle: .init(noHandle: .init()))) + service = AuthenticationService(userSessionStore: userSessionStore, encryptionKeyProvider: encryptionKeyProvider, - classicAppManager: nil, + classicAppManager: classicAppManager, clientFactory: clientFactory, appSettings: ServiceLocator.shared.settings, appHooks: AppHooks()) + + if let classicAppAccount = service.classicAppAccount { + await service.setupClassicAppAccountState() + try #require(classicAppAccount.state.isServerSupported == true) + try #require(classicAppAccount.state.availableSecrets == availableSecrets) + } } } diff --git a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift index 64cb07a0a..484dcf143 100644 --- a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift +++ b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift @@ -15,6 +15,8 @@ import UIKit final class AuthenticationStartScreenViewModelTests { var clientFactory: AuthenticationClientFactoryMock! var client: ClientSDKMock! + var classicAppManager: ClassicAppManagerMock? + var notificationCenter: NotificationCenter! var appSettings: AppSettings! var authenticationService: AuthenticationServiceProtocol! @@ -37,7 +39,7 @@ final class AuthenticationStartScreenViewModelTests { @Test func initialState() async throws { // Given a view model that has no provisioning parameters. - setupViewModel() + await setupViewModel() #expect(authenticationService.homeserver.value.loginMode == .unknown) #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) @@ -64,7 +66,7 @@ final class AuthenticationStartScreenViewModelTests { @Test func provisionedOIDCState() 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")) + await setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com")) #expect(authenticationService.homeserver.value.loginMode == .unknown) #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) @@ -84,7 +86,7 @@ final class AuthenticationStartScreenViewModelTests { @Test func provisionedPasswordState() 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) + await setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"), supportsOIDC: false) #expect(authenticationService.homeserver.value.loginMode == .unknown) #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) @@ -103,7 +105,7 @@ final class AuthenticationStartScreenViewModelTests { func singleProviderOIDCState() async throws { // Given a view model that for an app that only allows the use of a single provider that supports OIDC. setAllowedAccountProviders(["company.com"]) - setupViewModel() + await setupViewModel() #expect(authenticationService.homeserver.value.loginMode == .unknown) #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) @@ -124,7 +126,7 @@ final class AuthenticationStartScreenViewModelTests { func singleProviderPasswordState() async throws { // Given a view model that for an app that only allows the use of a single provider that does not support OIDC. setAllowedAccountProviders(["company.com"]) - setupViewModel(supportsOIDC: false) + await setupViewModel(supportsOIDC: false) #expect(authenticationService.homeserver.value.loginMode == .unknown) #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0) @@ -139,34 +141,202 @@ final class AuthenticationStartScreenViewModelTests { #expect(authenticationService.homeserver.value.loginMode == .password) } + // MARK: - Classic App Account + + @Test + func classicAppAccount() async throws { + // Given a view model with a Classic app account whose server name resolves successfully. + let classicAppAccount = makeClassicAppAccount() + await setupViewModel(classicAppAccount: classicAppAccount) + guard case .welcomeBack(let account) = context.viewState.classicAppMode else { + Issue.record("Expected classicAppMode to be .welcomeBack") + return + } + #expect(account == classicAppAccount) + + // When continuing with the Classic app account 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: .continueWithClassic(classicAppAccount)) + try await deferred.fulfill() + + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.homeserverAddress == "company.com") + #expect(authenticationService.homeserver.value.loginMode == .oidc(supportsCreatePrompt: false)) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)") + } + + @Test + func classicAppAccountWithoutWellKnown() async throws { + // Given a view model where the Classic app account's server name has no well-known file. + let classicAppAccount = makeClassicAppAccount(serverName: "unknown-server.org", + homeserverURL: "https://matrix.company.com") + await setupViewModel(classicAppAccount: classicAppAccount) + guard case .welcomeBack(let account) = context.viewState.classicAppMode else { + Issue.record("Expected classicAppMode to be .welcomeBack") + return + } + #expect(account == classicAppAccount) + + // When continuing with the Classic app account the authentication service should be used with the direct homeserver URL + // and the screen should request to continue the flow without any server selection needed. + let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC } + context.send(viewAction: .continueWithClassic(classicAppAccount)) + try await deferred.fulfill() + + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2) + #expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.homeserverAddress == "https://matrix.company.com") + #expect(authenticationService.homeserver.value.loginMode == .oidc(supportsCreatePrompt: false)) + #expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)") + } + + @Test + func classicAppAccountOnUnsupportedServer() async { + // Given a view model with a Classic app account whose server supports neither OIDC nor password login. + let classicAppAccount = makeClassicAppAccount() + await setupViewModel(classicAppAccount: classicAppAccount, supportsOIDC: false, supportsPasswordLogin: false) + guard case .welcomeBack(let account) = context.viewState.classicAppMode else { + Issue.record("Expected classicAppMode to be .welcomeBack") + return + } + #expect(account == classicAppAccount) + + // Then the Classic app account should indicate that it isn't supported (so the view falls back to the standard content). + #expect(account.state.isServerSupported == false) + } + + @Test + func classicAppAccountWithProvisioningLink() async { + // Given a view model that has been provisioned with a provisioning link (and a classic account exists). + let classicAppAccount = makeClassicAppAccount() + await setupViewModel(classicAppAccount: classicAppAccount, + provisioningParameters: .init(accountProvider: "company.com", loginHint: nil)) + + // Then the Classic app account should not be shown — provisioning takes precedence. + #expect(context.viewState.classicAppMode == nil) + } + + @Test + func singleProviderWithMatchingClassicAppAccount() async { + // Given a view model for an app that only allows a single provider that matches the Classic account's server. + let classicAppAccount = makeClassicAppAccount(serverName: "company.com", + homeserverURL: "https://matrix.company.com") + setAllowedAccountProviders(["company.com"]) + await setupViewModel(classicAppAccount: classicAppAccount) + + // Then the Classic app account should be shown as a welcome-back option. + guard case .welcomeBack(let account) = context.viewState.classicAppMode else { + Issue.record("Expected classicAppMode to be .welcomeBack") + return + } + #expect(account == classicAppAccount) + } + + @Test + func singleProviderWithDisallowedClassicAppAccount() async { + // Given a view model for an app that only allows a single provider that does NOT match the Classic account's server. + let classicAppAccount = makeClassicAppAccount(serverName: "other-server.org", + homeserverURL: "https://matrix.other-server.org") + setAllowedAccountProviders(["company.com"]) + await setupViewModel(classicAppAccount: classicAppAccount) + + // Then the Classic app account should not be shown since the server is not in the allowed providers. + #expect(context.viewState.classicAppMode == nil) + } + + @Test + func classicAppAccountRequiresBackup() async throws { + // Given a view model with a Classic app account that requires backup before signing in. + let classicAppAccount = makeClassicAppAccount() + await setupViewModel(classicAppAccount: classicAppAccount, availableSecrets: .requiresBackup) + guard case .welcomeBack(let account) = context.viewState.classicAppMode else { + Issue.record("Expected classicAppMode to be .welcomeBack") + return + } + #expect(account.state.availableSecrets == .requiresBackup) + + // When continuing with the Classic account while backup is required. + var deferred = deferFulfillment(context.observe(\.viewState.bindings.showClassicAppBackupInstructions)) { $0 } + context.send(viewAction: .continueWithClassic(classicAppAccount)) + + // Then the backup instructions should be shown. + try await deferred.fulfill() + + // When the user completes the backup in the Classic app and the app returns to the foreground. + classicAppManager?.availableSecretsForReturnValue = .complete + deferred = deferFulfillment(context.observe(\.viewState.bindings.showClassicAppBackupInstructions)) { !$0 } + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) + + // Then the backup instructions sheet should be dismissed. + try await deferred.fulfill() + + // When the user continues with the Classic account again. + let deferredAction = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC } + context.send(viewAction: .continueWithClassic(classicAppAccount)) + + // Then the flow should continue the login process. + try await deferredAction.fulfill() + } + // MARK: - Helpers - private func setupViewModel(provisioningParameters: AccountProvisioningParameters? = nil, supportsOIDC: Bool = true) { + private func setupViewModel(classicAppAccount: ClassicAppAccount? = nil, + provisioningParameters: AccountProvisioningParameters? = nil, + supportsOIDC: Bool = true, + supportsPasswordLogin: Bool = true, + availableSecrets: ClassicAppAccount.AvailableSecrets = .complete) async { // 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 = AuthenticationClientFactoryMock.Configuration(homeserverClients: ["company.com": client]) + supportsPasswordLogin: supportsPasswordLogin)) + // Map both the server name and the homeserver URL so fallback lookups work. + let homeserverClients: [String: ClientSDKMock] = ["company.com": client, + "https://matrix.company.com": client] + let configuration = AuthenticationClientFactoryMock.Configuration(homeserverClients: homeserverClients) + + if let classicAppAccount { + classicAppManager = ClassicAppManagerMock(.init(accounts: [classicAppAccount], availableSecrets: availableSecrets)) + } else { + classicAppManager = nil + } + + notificationCenter = NotificationCenter() clientFactory = AuthenticationClientFactoryMock(configuration: configuration) authenticationService = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), encryptionKeyProvider: EncryptionKeyProvider(), - classicAppManager: nil, + classicAppManager: classicAppManager, clientFactory: clientFactory, appSettings: appSettings, appHooks: AppHooks()) + await authenticationService.setupClassicAppAccountState() + viewModel = AuthenticationStartScreenViewModel(authenticationService: authenticationService, provisioningParameters: provisioningParameters, isBugReportServiceEnabled: true, + appMediator: AppMediatorMock(), appSettings: appSettings, mediaProvider: MediaProviderMock(configuration: .init()), + notificationCenter: notificationCenter, userIndicatorController: UserIndicatorControllerMock()) // Add a fake window in order for the OIDC flow to continue viewModel.context.send(viewAction: .updateWindow(UIWindow())) } + private func makeClassicAppAccount(serverName: String = "company.com", + homeserverURL: URL = "https://matrix.company.com") -> ClassicAppAccount { + ClassicAppAccount(userID: "@user:\(serverName)", + displayName: "Classic User", + avatarURL: nil, + serverName: serverName, + homeserverURL: homeserverURL, + cryptoStoreURL: "file:///tmp/crypto-store", + cryptoStorePassphrase: "passphrase", + accessToken: "accessToken") + } + private func setAllowedAccountProviders(_ providers: [String]) { appSettings.override(accountProviders: providers, allowOtherAccountProviders: false, diff --git a/app.yml b/app.yml index 2c398ab62..51507a82e 100644 --- a/app.yml +++ b/app.yml @@ -10,3 +10,4 @@ settings: CLASSIC_APP_GROUP_IDENTIFIER: group.im.vector CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER: im.vector.app.encryption-manager-service CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared" + CLASSIC_APP_DEEP_LINK_URL: "element://open"