Verify Element X with an existing Element Classic account. (#5374)

* Read and import the secrets from ClassicAppAccounts.

* Record snapshots.

* Add some documentation, tidy up tests and fix the dismissal of the backup instructions.

* Workaround flakey tests (the fulfilments weren't always firing).

* Allow a custom Classic App deep link URL to be configured.
This commit is contained in:
Doug
2026-04-13 15:30:09 +01:00
committed by GitHub
parent f146ba835d
commit 252e2f75df
40 changed files with 947 additions and 103 deletions

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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")

View File

@@ -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<ClientProxyAction, Never> {

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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
}

View File

@@ -14,6 +14,7 @@ typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2<Authent
class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol {
private let authenticationService: AuthenticationServiceProtocol
private let provisioningParameters: AccountProvisioningParameters?
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let userIndicatorController: UserIndicatorControllerProtocol
@@ -28,16 +29,21 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
init(authenticationService: AuthenticationServiceProtocol,
provisioningParameters: AccountProvisioningParameters?,
isBugReportServiceEnabled: Bool,
appMediator: AppMediatorProtocol,
appSettings: AppSettings,
mediaProvider: MediaProviderProtocol?,
notificationCenter: NotificationCenter = .default,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.provisioningParameters = provisioningParameters
self.appMediator = appMediator
self.appSettings = appSettings
self.userIndicatorController = userIndicatorController
canReportProblem = isBugReportServiceEnabled
let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
let classicAppAccountProvider = authenticationService.classicAppAccount?.serverName
let isClassicAppAccountAllowed = classicAppAccountProvider.map { appSettings.accountProviders.contains($0) } ?? false
let initialViewState = if !appSettings.allowOtherAccountProviders {
// We don't show the create account button when custom providers are disallowed.
@@ -45,22 +51,31 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
AuthenticationStartScreenViewState(serverName: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0] : nil,
showCreateAccountButton: false,
showQRCodeLoginButton: isQRCodeScanningSupported,
classicAppMode: isClassicAppAccountAllowed ? authenticationService.classicAppAccount.map { .welcomeBack($0) } : nil,
hideBrandChrome: appSettings.hideBrandChrome)
} else if let provisioningParameters {
// We only show the "Sign in to " button when using a provisioning link.
AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider,
showCreateAccountButton: false,
showQRCodeLoginButton: false,
classicAppMode: nil,
hideBrandChrome: appSettings.hideBrandChrome)
} else {
// The default configuration.
AuthenticationStartScreenViewState(serverName: nil,
showCreateAccountButton: appSettings.showCreateAccountButton,
showQRCodeLoginButton: isQRCodeScanningSupported,
classicAppMode: authenticationService.classicAppAccount.map { .welcomeBack($0) },
hideBrandChrome: appSettings.hideBrandChrome)
}
super.init(initialViewState: initialViewState, mediaProvider: mediaProvider)
notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { [weak self] _ in
self?.reloadClassicAppAccount()
}
.store(in: &cancellables)
}
override func process(viewAction: AuthenticationStartScreenViewAction) {
@@ -78,29 +93,51 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
if canReportProblem {
actionsSubject.send(.reportProblem)
}
case .continueWithClassic, .otherOptions, .closeOtherOptions:
break // To follow.
case .continueWithClassic(let account):
Task { await login(classicAppAccount: account) }
case .otherOptions(let account):
state.classicAppMode = .otherOptions(account)
case .closeOtherOptions(let account):
state.classicAppMode = .welcomeBack(account)
case .openClassicApp:
guard let classicAppDeepLinkURL = InfoPlistReader.main.classicAppDeepLinkURL else { return }
appMediator.open(classicAppDeepLinkURL)
}
}
// MARK: - Private
private func login() async {
if let serverName = state.serverName {
private func login(classicAppAccount: ClassicAppAccount? = nil) async {
if let classicAppAccount {
if classicAppAccount.state.availableSecrets == .requiresBackup {
state.bindings.showClassicAppBackupInstructions = true
} else {
await configureAccountProvider(classicAppAccount.serverName,
loginHint: "mxid:\(classicAppAccount.userID)",
fallbackHomeserverURL: classicAppAccount.homeserverURL)
}
} else if let serverName = state.serverName {
await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint)
} else {
actionsSubject.send(.login) // No need to configure anything here, continue the flow.
}
}
private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil) async {
private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil, fallbackHomeserverURL: URL? = nil) async {
startLoading()
defer { stopLoading() }
guard case .success = await authenticationService.configure(for: 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
if case .failure = await authenticationService.configure(for: accountProvider, flow: .login) {
// Try the fallback URL before showing an error.
if let fallbackHomeserverURL,
case .success = await authenticationService.configure(for: fallbackHomeserverURL.absoluteString, flow: .login) {
// Fallback succeeded, continue with the flow.
} else {
// As the server was provisioned, we don't worry about the specifics and show a generic error to the user.
// Element Classic accounts aren't shown for unsupported servers either, so nothing to do here.
displayError()
return
}
}
guard authenticationService.homeserver.value.loginMode.supportsOIDCFlow else {
@@ -121,6 +158,23 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
}
}
@CancellableTask private var reloadClassicAppSecretsTask: Task<Void, Never>?
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() {

View File

@@ -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())

View File

@@ -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")
}
}

View File

@@ -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())
}
}

View File

@@ -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()
}
}

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -124,6 +124,8 @@
<string>$(APP_GROUP_IDENTIFIER)</string>
<key>baseBundleIdentifier</key>
<string>$(BASE_BUNDLE_IDENTIFIER)</string>
<key>classicAppDeepLinkURL</key>
<string>$(CLASSIC_APP_DEEP_LINK_URL)</string>
<key>classicAppGroupIdentifier</key>
<string>$(CLASSIC_APP_GROUP_IDENTIFIER)</string>
<key>classicAppKeychainAccessGroupIdentifier</key>

View File

@@ -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: [