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 001a389f51
commit 7c812201cb
40 changed files with 947 additions and 103 deletions
@@ -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)
@@ -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
}
@@ -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() {
@@ -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())
@@ -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")
}
}
@@ -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())
}
}