Files
element-x-ios/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift
T
joel.poyet 1da4c0302e Point the classic app identifiers at HealthChat rather than Element (#1)
ClassicAppManager migrates an existing user's session and encryption keys by
reading the legacy app's app-group container and keychain. Those identifiers
defaulted to Element's own, which this team cannot provision, so loadAccounts()
threw invalidAppGroupIdentifier — caught and logged, never surfaced. Since both
apps share a bundle id, the result would be every existing user silently logged
out and losing local history when they update.

The legacy identifiers belong to HealthChat and are provisionable under the same
team, so repoint them rather than dropping them.

Also stop sending accounts without key backup to a dead end. Upstream shows those
users a sheet asking them to open Element Classic and set up backup first, but this
update replaces that app and nothing registers its URL scheme, so the sheet's only
button does nothing. Sign in directly instead: the user re-verifies the device and
loses only history that by definition has no backup.
2026-10-19 09:17:29 +02:00

198 lines
9.6 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2<AuthenticationStartScreenViewState, AuthenticationStartScreenViewAction>
class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol {
private let authenticationService: AuthenticationServiceProtocol
private let provisioningParameters: AccountProvisioningParameters?
private let appMediator: AppMediatorProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let canReportProblem: Bool
private var actionsSubject: PassthroughSubject<AuthenticationStartScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<AuthenticationStartScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
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.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.
// The assumption here being that if you're running a custom app, your users will already be created.
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) {
switch viewAction {
case .updateWindow(let window):
guard state.window != window else { return }
state.window = window
case .reportProblem:
if canReportProblem {
actionsSubject.send(.reportProblem)
}
case .developerOptions:
actionsSubject.send(.developerOptions)
case .loginWithQR:
actionsSubject.send(.loginWithQR)
case .login:
Task { await login() }
case .register:
actionsSubject.send(.register)
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(classicAppAccount: ClassicAppAccount? = nil) async {
if let classicAppAccount {
// HealthChat diverges from upstream here. Upstream sends `.requiresBackup` accounts to a sheet
// asking the user to set up key backup in Element Classic first. HealthChat ships with the same
// bundle id as the legacy app, so this update *replaces* it — there is no Classic app left to
// open, and the sheet's only button resolves to nothing. Fall through to a normal sign-in
// instead: the user re-verifies and loses only history that by definition has no backup.
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, fallbackHomeserverURL: URL? = nil) async {
startLoading()
defer { stopLoading() }
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.supportsOAuthFlow else {
actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint))
return
}
guard let window = state.window else {
displayError()
return
}
switch await authenticationService.urlForOAuthLogin(loginHint: loginHint) {
case .success(let oAuthData):
actionsSubject.send(.loginDirectlyWithOAuth(data: oAuthData, window: window))
case .failure:
displayError()
}
}
@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() {
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorID,
type: .modal,
title: L10n.commonLoading,
persistent: true))
}
private func stopLoading() {
userIndicatorController.retractIndicatorWithId(loadingIndicatorID)
}
private func displayError() {
state.bindings.alertInfo = AlertInfo(id: .genericError)
}
}