mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
34523cf51a
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.
198 lines
9.6 KiB
Swift
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)
|
|
}
|
|
}
|