Show an alert when entering an account provider that requires Element Pro. (#4326)

This commit is contained in:
Doug
2025-07-17 13:41:26 +01:00
committed by GitHub
parent 247c7a4617
commit f655fbc28b
30 changed files with 253 additions and 5 deletions
+8
View File
@@ -869,12 +869,14 @@
A4C29D373986AFE4559696D5 /* SecureBackupKeyBackupScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4525E8C0FBDD27D1ACE90952 /* SecureBackupKeyBackupScreenViewModelProtocol.swift */; };
A4E885358D7DD5A072A06824 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = CCE5BF78B125320CBF3BB834 /* PostHog */; };
A51C65E5A3C9F2464A91A380 /* AuthenticationClientBuilderFactoryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0554FEA301486A8CFA475D5A /* AuthenticationClientBuilderFactoryMock.swift */; };
A52090A4FE0DB826578DFC03 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0724EBDFE8BB4C9E5547C57D /* Client.swift */; };
A583B70939707197B0B21DFC /* DeferredFulfillmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBB74427E24AF30CDB131B7 /* DeferredFulfillmentTests.swift */; };
A588572ED0EB18D947B32A5E /* SendInviteConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F276F31C1AEC19E52B951B62 /* SendInviteConfirmationView.swift */; };
A5B455D1A6DADF7476F7B417 /* EmojiProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCCE3D12B0A9C6E559B5B5A /* EmojiProviderProtocol.swift */; };
A5B9EF45C7B8ACEB4954AE36 /* LoginScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9780389F8A53E4D26E23DD03 /* LoginScreenViewModelProtocol.swift */; };
A5D551E5691749066E0E0C44 /* RoomDetailsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B440C4705E4B899BCB899 /* RoomDetailsScreenViewModel.swift */; };
A5FD8284744E2FECFC842FC1 /* TraceLogPack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7149BDDE47F8AD104E644E2 /* TraceLogPack.swift */; };
A63DA8A6D229889273A53D99 /* ElementWellKnownHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142FAE169CC4619D8A30262 /* ElementWellKnownHook.swift */; };
A64B52D9F73F9A6B95AF24FE /* UserDetailsEditScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CD503F5E0938FE53C7C6E7 /* UserDetailsEditScreenCoordinator.swift */; };
A6B83EB78F025D21B6EBA90C /* CompoundIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 044E501B8331B339874D1B96 /* CompoundIcon.swift */; };
A6D4C5EEA85A6A0ABA1559D6 /* RoomDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */; };
@@ -1399,6 +1401,7 @@
00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelTests.swift; sourceTree = "<group>"; };
011AFA4990C585D157829679 /* DeclineAndBlockScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenViewModel.swift; sourceTree = "<group>"; };
012A284622B32052015F1F89 /* ReadMarkerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineView.swift; sourceTree = "<group>"; };
0142FAE169CC4619D8A30262 /* ElementWellKnownHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementWellKnownHook.swift; sourceTree = "<group>"; };
018194CAFBE80720FECCEDEE /* ZoomTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomTransition.swift; sourceTree = "<group>"; };
01B795AAAB7B8747FE2FF311 /* LogViewerScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenModels.swift; sourceTree = "<group>"; };
01C4C7DB37597D7D8379511A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -1435,6 +1438,7 @@
06B098A612DCB5A7358EECD5 /* DeveloperOptionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenModels.swift; sourceTree = "<group>"; };
06F27F588F9059128E17C669 /* WindowManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManagerProtocol.swift; sourceTree = "<group>"; };
06FAE373A7F20780BA84B59C /* MessageForwardingScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenCoordinator.swift; sourceTree = "<group>"; };
0724EBDFE8BB4C9E5547C57D /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; };
07579F9C29001E40715F3014 /* NotificationSettingsChatType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsChatType.swift; sourceTree = "<group>"; };
077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfo.swift; sourceTree = "<group>"; };
077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorStateMachine.swift; sourceTree = "<group>"; };
@@ -3271,6 +3275,7 @@
3865AD7B7249C939D7C69C33 /* CertificateValidatorHook.swift */,
7AC0CD1CAFD3F8B057F9AEA5 /* ClientBuilderHook.swift */,
8B89D6C760E8CAE29CA28FB1 /* CompoundHook.swift */,
0142FAE169CC4619D8A30262 /* ElementWellKnownHook.swift */,
B343C5255FB408DDE853CFDF /* RoomScreenHook.swift */,
);
path = Hooks;
@@ -4674,6 +4679,7 @@
8039515BAA53B7C3275AC64A /* Client */ = {
isa = PBXGroup;
children = (
0724EBDFE8BB4C9E5547C57D /* Client.swift */,
18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */,
6033779EB37259F27F938937 /* ClientProxyProtocol.swift */,
);
@@ -7261,6 +7267,7 @@
E14E469CD97550D0FC58F3CA /* CancellableTask.swift in Sources */,
DF8F1211F2B0B56F0FCCA5C2 /* CertificateValidatorHook.swift in Sources */,
D885B783B95AD7832D4EF5DD /* CharacterSet.swift in Sources */,
A52090A4FE0DB826578DFC03 /* Client.swift in Sources */,
C80E06ED97CE52704A46C148 /* ClientBuilder.swift in Sources */,
87CEA3E07B602705BC2D2A20 /* ClientBuilderHook.swift in Sources */,
1950A80CD198BED283DFC2CE /* ClientProxy.swift in Sources */,
@@ -7336,6 +7343,7 @@
07CC13C5729C24255348CBBD /* ElementCallWidgetDriver.swift in Sources */,
370AF5BFCD4384DD455479B6 /* ElementCallWidgetDriverProtocol.swift in Sources */,
A87DC550659C5176AC1829DE /* ElementTextFieldStyle.swift in Sources */,
A63DA8A6D229889273A53D99 /* ElementWellKnownHook.swift in Sources */,
7C1A7B594B2F8143F0DD0005 /* ElementXAttributeScope.swift in Sources */,
3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */,
340D39DB87F3800D53A6A621 /* EmojiPickerScreen.swift in Sources */,
@@ -659,6 +659,9 @@
"screen_change_account_provider_other" = "Other";
"screen_change_account_provider_subtitle" = "Use a different account provider, such as your own private server or a work account.";
"screen_change_account_provider_title" = "Change account provider";
"screen_change_server_error_element_pro_required_action_ios" = "App Store";
"screen_change_server_error_element_pro_required_message" = "The Element Pro app is required on %1$@. Please download it from the store.";
"screen_change_server_error_element_pro_required_title" = "Element Pro required";
"screen_change_server_error_invalid_homeserver" = "We couldn't reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help.";
"screen_change_server_error_invalid_well_known" = "Server isn't available due to an issue in the .well-known file:\n%1$@";
"screen_change_server_error_no_sliding_sync_message" = "The selected account provider does not support sliding sync. An upgrade to the server is needed to use %1$@.";
+5
View File
@@ -33,6 +33,11 @@ class AppHooks: AppHooksProtocol {
certificateValidatorHook = hook
}
private(set) var elementWellKnownHook: ElementWellKnownHookProtocol = DefaultElementWellKnownHook()
func registerElementWellKnownHook(_ hook: ElementWellKnownHookProtocol) {
elementWellKnownHook = hook
}
private(set) var roomScreenHook: RoomScreenHookProtocol = DefaultRoomScreenHook()
func registerRoomScreenHook(_ hook: RoomScreenHookProtocol) {
roomScreenHook = hook
@@ -0,0 +1,53 @@
//
// Copyright 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 Foundation
import MatrixRustSDK
protocol ElementWellKnownHookProtocol {
func validate(using client: ClientProtocol) async -> Result<Void, ElementWellKnownError>
}
struct DefaultElementWellKnownHook: ElementWellKnownHookProtocol {
/// A best effort implementation to let Element X advertise to users when they should be using
/// Element Pro. In an ideal world the backend would be able to validate the client's requests
/// instead of relying on it to check a well-known file for this.
func validate(using client: ClientProtocol) async -> Result<Void, ElementWellKnownError> {
guard case let .success(wellKnownData) = await client.elementWellKnown() else {
// Nothing to check, carry on as normal.
return .success(())
}
do {
let wellKnown = try JSONDecoder().decode(ElementWellKnown.self, from: wellKnownData)
if wellKnown.enforceElementPro == true {
let serverName = client.server() ?? client.homeserver()
let displayableServerName = LoginHomeserver(address: serverName, loginMode: .unknown).address
return .failure(.elementProRequired(serverName: displayableServerName))
} else {
return .success(())
}
} catch {
// If it doesn't decode we have to assume it's a 404 page or similar.
return .success(())
}
}
}
private struct ElementWellKnown: Decodable {
var version: Int
var enforceElementPro: Bool?
enum CodingKeys: String, CodingKey {
case version
case enforceElementPro = "enforce_element_pro"
}
}
enum ElementWellKnownError: Error {
case elementProRequired(serverName: String)
}
@@ -189,6 +189,9 @@ final class AppSettings {
private(set) var elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"]
/// The domain that account provisioning links will be hosted on - used for handling the links.
private(set) var accountProvisioningHost = "mobile.element.io"
/// The App Store URL for Element Pro, shown to the user when a homeserver requires that app.
/// **Note:** This property isn't overridable as it in unexpected for forks to come across the error (or to even have a "Pro" app).
let elementProAppStoreURL: URL = "https://apps.apple.com/app/element-pro-for-work/id6502951615"
@UserPreference(key: UserDefaultsKeys.appAppearance, defaultValue: .system, storageType: .userDefaults(store))
var appAppearance: AppAppearance
@@ -363,6 +363,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
let parameters = ServerSelectionScreenCoordinatorParameters(authenticationService: authenticationService,
authenticationFlow: authenticationFlow,
appSettings: appSettings,
userIndicatorController: userIndicatorController)
let coordinator = ServerSelectionScreenCoordinator(parameters: parameters)
@@ -408,6 +409,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
let parameters = LoginScreenCoordinatorParameters(authenticationService: authenticationService,
loginHint: loginHint,
userIndicatorController: userIndicatorController,
appSettings: appSettings,
analytics: analytics)
let coordinator = LoginScreenCoordinator(parameters: parameters)
+8
View File
@@ -1258,6 +1258,14 @@ internal enum L10n {
internal static var screenChangeAccountProviderSubtitle: String { return L10n.tr("Localizable", "screen_change_account_provider_subtitle") }
/// Change account provider
internal static var screenChangeAccountProviderTitle: String { return L10n.tr("Localizable", "screen_change_account_provider_title") }
/// App Store
internal static var screenChangeServerErrorElementProRequiredActionIos: String { return L10n.tr("Localizable", "screen_change_server_error_element_pro_required_action_ios") }
/// The Element Pro app is required on %1$@. Please download it from the store.
internal static func screenChangeServerErrorElementProRequiredMessage(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_change_server_error_element_pro_required_message", String(describing: p1))
}
/// Element Pro required
internal static var screenChangeServerErrorElementProRequiredTitle: String { return L10n.tr("Localizable", "screen_change_server_error_element_pro_required_title") }
/// We couldn't reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help.
internal static var screenChangeServerErrorInvalidHomeserver: String { return L10n.tr("Localizable", "screen_change_server_error_invalid_homeserver") }
/// Server isn't available due to an issue in the .well-known file:
@@ -25,11 +25,18 @@ extension AuthenticationClientFactoryMock {
supportsOIDCCreatePrompt: false,
supportsPasswordLogin: false)),
"server.net": ClientSDKMock(configuration: .init(serverAddress: "server.net",
homeserverURL: "https://matrix.example.com",
homeserverURL: "https://matrix.server.net",
slidingSyncVersion: .native,
oidcLoginURL: nil,
supportsOIDCCreatePrompt: false,
supportsPasswordLogin: false))
supportsPasswordLogin: false)),
"secure.gov": ClientSDKMock(configuration: .init(serverAddress: "secure.gov",
homeserverURL: "https://ess.secure.gov",
slidingSyncVersion: .native,
oidcLoginURL: "https://auth.secure.gov/oidc",
supportsOIDCCreatePrompt: false,
supportsPasswordLogin: false,
elementWellKnown: "{\"version\":1,\"enforce_element_pro\":true}"))
]
}
@@ -18,6 +18,7 @@ extension ClientSDKMock {
var oidcLoginURL: String? = "https://account.matrix.org/authorize"
var supportsOIDCCreatePrompt = true
var supportsPasswordLogin = true
var elementWellKnown: String?
var validCredentials = (username: "alice", password: "12345678")
// MARK: Session
@@ -51,6 +52,14 @@ extension ClientSDKMock {
userIdReturnValue = configuration.userID
sessionReturnValue = configuration.session
getUrlUrlClosure = { url in
guard url.contains(".well-known/element/element.json") else { throw MockError.generic }
if let elementWellKnown = configuration.elementWellKnown {
return elementWellKnown
} else {
throw MockError.generic
}
}
}
}
@@ -14,6 +14,7 @@ struct LoginScreenCoordinatorParameters {
/// An optional hint that can be used to pre-fill the form.
let loginHint: String?
let userIndicatorController: UserIndicatorControllerProtocol
let appSettings: AppSettings
let analytics: AnalyticsService
}
@@ -46,6 +47,7 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
viewModel = LoginScreenViewModel(authenticationService: parameters.authenticationService,
loginHint: parameters.loginHint,
userIndicatorController: parameters.userIndicatorController,
appSettings: parameters.appSettings,
analytics: parameters.analytics)
}
@@ -70,6 +70,8 @@ enum LoginScreenErrorType: Hashable {
case invalidWellKnownAlert(String)
/// An alert that allows the user to learn about sliding sync.
case slidingSyncAlert
/// An alert that informs the user that Element Pro should be used for a particular server.
case elementProAlert
/// An alert that informs the user that login failed due to a refresh token being returned.
case refreshTokenAlert
/// The response from the homeserver was unexpected.
@@ -13,6 +13,7 @@ typealias LoginScreenViewModelType = StateStoreViewModelV2<LoginScreenViewState,
class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtocol {
private let authenticationService: AuthenticationServiceProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appSettings: AppSettings
private let analytics: AnalyticsService
private var actionsSubject: PassthroughSubject<LoginScreenViewModelAction, Never> = .init()
@@ -23,9 +24,11 @@ class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtoc
init(authenticationService: AuthenticationServiceProtocol,
loginHint: String?,
userIndicatorController: UserIndicatorControllerProtocol,
appSettings: AppSettings,
analytics: AnalyticsService) {
self.authenticationService = authenticationService
self.userIndicatorController = userIndicatorController
self.appSettings = appSettings
self.analytics = analytics
let username = switch loginHint {
@@ -146,6 +149,16 @@ class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtoc
// Clear out the invalid username to avoid an attempted login to matrix.org
state.bindings.username = ""
case .elementProRequired(let serverName):
state.bindings.alertInfo = AlertInfo(id: .elementProAlert,
title: L10n.screenChangeServerErrorElementProRequiredTitle,
message: L10n.screenChangeServerErrorElementProRequiredMessage(serverName),
primaryButton: .init(title: L10n.screenChangeServerErrorElementProRequiredActionIos) {
UIApplication.shared.open(self.appSettings.elementProAppStoreURL)
},
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
// Clear out the invalid username to avoid an attempted login to matrix.org
state.bindings.username = ""
case .sessionTokenRefreshNotSupported:
state.bindings.alertInfo = AlertInfo(id: .refreshTokenAlert,
title: L10n.commonServerNotSupported,
@@ -159,6 +159,7 @@ struct LoginScreen_Previews: PreviewProvider, TestablePreview {
let viewModel = LoginScreenViewModel(authenticationService: authenticationService,
loginHint: nil,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics)
if withCredentials {
@@ -40,6 +40,7 @@ final class ServerConfirmationScreenCoordinator: CoordinatorProtocol {
viewModel = ServerConfirmationScreenViewModel(authenticationService: parameters.authenticationService,
mode: mode,
authenticationFlow: parameters.authenticationFlow,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController)
}
@@ -94,6 +94,8 @@ enum ServerConfirmationScreenAlert: Hashable {
case login
/// An alert that informs the user that registration isn't supported.
case registration
/// An alert that informs the user that Element Pro should be used for a particular server.
case elementProRequired(serverName: String)
/// An unknown error has occurred.
case unknownError
}
@@ -13,6 +13,7 @@ typealias ServerConfirmationScreenViewModelType = StateStoreViewModelV2<ServerCo
class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType, ServerConfirmationScreenViewModelProtocol {
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let appSettings: AppSettings
let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<ServerConfirmationScreenViewModelAction, Never> = .init()
@@ -24,9 +25,11 @@ class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType,
init(authenticationService: AuthenticationServiceProtocol,
mode: ServerConfirmationScreenMode,
authenticationFlow: AuthenticationFlow,
appSettings: AppSettings,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.authenticationFlow = authenticationFlow
self.appSettings = appSettings
self.userIndicatorController = userIndicatorController
let pickerSelection: String? = switch mode {
@@ -94,6 +97,8 @@ class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType,
displayError(.login)
case .registrationNotSupported:
displayError(.registration)
case .elementProRequired(let serverName):
displayError(.elementProRequired(serverName: serverName))
default:
displayError(.unknownError)
}
@@ -184,6 +189,14 @@ class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType,
state.bindings.alertInfo = AlertInfo(id: .registration,
title: L10n.commonServerNotSupported,
message: L10n.errorAccountCreationNotPossible)
case .elementProRequired(let serverName):
state.bindings.alertInfo = AlertInfo(id: .elementProRequired(serverName: serverName),
title: L10n.screenChangeServerErrorElementProRequiredTitle,
message: L10n.screenChangeServerErrorElementProRequiredMessage(serverName),
primaryButton: .init(title: L10n.screenChangeServerErrorElementProRequiredActionIos) {
UIApplication.shared.open(self.appSettings.elementProAppStoreURL)
},
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
case .unknownError:
state.bindings.alertInfo = AlertInfo(id: .unknownError)
}
@@ -162,6 +162,7 @@ struct ServerConfirmationScreen_Previews: PreviewProvider, TestablePreview {
ServerConfirmationScreenViewModel(authenticationService: AuthenticationService.mock,
mode: mode,
authenticationFlow: flow,
appSettings: ServiceLocator.shared.settings,
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -12,6 +12,7 @@ struct ServerSelectionScreenCoordinatorParameters {
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let appSettings: AppSettings
let userIndicatorController: UserIndicatorControllerProtocol
}
@@ -38,6 +39,7 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = ServerSelectionScreenViewModel(authenticationService: parameters.authenticationService,
authenticationFlow: parameters.authenticationFlow,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController)
userIndicatorController = parameters.userIndicatorController
}
@@ -66,4 +66,6 @@ enum ServerSelectionScreenErrorType: Hashable {
case loginAlert
/// An alert that informs the user that registration isn't supported.
case registrationAlert
/// An alert that informs the user that Element Pro should be used for a particular server.
case elementProAlert
}
@@ -13,6 +13,7 @@ typealias ServerSelectionScreenViewModelType = StateStoreViewModelV2<ServerSelec
class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, ServerSelectionScreenViewModelProtocol {
private let authenticationService: AuthenticationServiceProtocol
private let authenticationFlow: AuthenticationFlow
private let appSettings: AppSettings
private let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<ServerSelectionScreenViewModelAction, Never> = .init()
@@ -23,9 +24,11 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
init(authenticationService: AuthenticationServiceProtocol,
authenticationFlow: AuthenticationFlow,
appSettings: AppSettings,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.authenticationFlow = authenticationFlow
self.appSettings = appSettings
self.userIndicatorController = userIndicatorController
let bindings = ServerSelectionScreenBindings(homeserverAddress: authenticationService.homeserver.value.address)
@@ -96,6 +99,14 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
state.bindings.alertInfo = AlertInfo(id: .registrationAlert,
title: L10n.commonServerNotSupported,
message: L10n.errorAccountCreationNotPossible)
case .elementProRequired(let serverName):
state.bindings.alertInfo = AlertInfo(id: .elementProAlert,
title: L10n.screenChangeServerErrorElementProRequiredTitle,
message: L10n.screenChangeServerErrorElementProRequiredMessage(serverName),
primaryButton: .init(title: L10n.screenChangeServerErrorElementProRequiredActionIos) {
UIApplication.shared.open(self.appSettings.elementProAppStoreURL)
},
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
default:
showFooterMessage(L10n.errorUnknown)
}
@@ -115,6 +115,7 @@ struct ServerSelection_Previews: PreviewProvider, TestablePreview {
let viewModel = ServerSelectionScreenViewModel(authenticationService: authenticationService,
authenticationFlow: .login,
appSettings: ServiceLocator.shared.settings,
userIndicatorController: UserIndicatorControllerMock())
viewModel.context.homeserverAddress = homeserverAddress
if homeserverAddress == "thisisbad" {
@@ -52,6 +52,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
var homeserver = LoginHomeserver(address: homeserverAddress, loginMode: .unknown)
let client = try await makeClient(homeserverAddress: homeserverAddress)
try await appHooks.elementWellKnownHook.validate(using: client).get()
let loginDetails = await client.homeserverLoginDetails()
MXLog.info("Sliding sync: \(client.slidingSyncVersion())")
@@ -81,6 +82,8 @@ class AuthenticationService: AuthenticationServiceProtocol {
} catch ClientBuildError.SlidingSyncVersion(let error) {
MXLog.info("User entered a homeserver that isn't configured for sliding sync: \(error)")
return .failure(.slidingSyncNotAvailable)
} catch ElementWellKnownError.elementProRequired(let serverName) {
return .failure(.elementProRequired(serverName: serverName))
} catch {
MXLog.error("Failed configuring a server: \(error)")
return .failure(.invalidHomeserverAddress)
@@ -176,6 +179,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
do {
let client = try await makeClient(homeserverAddress: scannedServerName)
try await appHooks.elementWellKnownHook.validate(using: client).get()
try await client.loginWithQrCode(qrCodeData: qrData,
oidcConfiguration: appSettings.oidcConfiguration.rustValue,
progressListener: listener)
@@ -184,6 +188,8 @@ class AuthenticationService: AuthenticationServiceProtocol {
} catch let error as HumanQrLoginError {
MXLog.error("QRCode login error: \(error)")
return .failure(error.serviceError)
} catch ElementWellKnownError.elementProRequired(let serverName) {
return .failure(.elementProRequired(serverName: serverName))
} catch {
MXLog.error("QRCode login unknown error: \(error)")
return .failure(.qrCodeError(.unknown))
@@ -30,6 +30,7 @@ enum AuthenticationServiceError: Error, Equatable {
case slidingSyncNotAvailable
case loginNotSupported
case registrationNotSupported
case elementProRequired(serverName: String)
case accountDeactivated
case failedLoggingIn
case sessionTokenRefreshNotSupported
@@ -0,0 +1,35 @@
//
// Copyright 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 Foundation
import MatrixRustSDK
extension ClientProtocol {
func elementWellKnown() async -> Result<Data, ClientProxyError> {
let serverNameURLString = if let userIDServerName = try? userIdServerName() {
"https://\(userIDServerName)"
} else {
server() ?? homeserver()
}
do {
guard let url = URL(string: serverNameURLString)?.appending(path: "/.well-known/element/element.json") else {
return .failure(.invalidServerName)
}
let response = try await getUrl(url: url.absoluteString)
guard let data = response.data(using: .utf8) else {
return .failure(.invalidResponse)
}
return .success(data)
} catch {
return .failure(.sdkError(error))
}
}
}
@@ -34,6 +34,7 @@ enum ClientProxyError: Error {
case invalidMedia
case invalidServerName
case invalidResponse
case failedUploadingMedia(ErrorKind)
case roomPreviewIsPrivate
case failedRetrievingUserIdentity
@@ -117,6 +117,7 @@ class MockScreen: Identifiable {
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = ServerSelectionScreenCoordinator(parameters: .init(authenticationService: AuthenticationService.mock,
authenticationFlow: .login,
appSettings: ServiceLocator.shared.settings,
userIndicatorController: ServiceLocator.shared.userIndicatorController))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
@@ -143,6 +143,21 @@ class LoginScreenViewModelTests: XCTestCase {
XCTAssertEqual(context.alertInfo?.id, .unknown, "An alert should be shown to the user.")
}
func testElementProRequired() async throws {
// Given the screen configured for matrix.org
await setupViewModel()
XCTAssertNil(context.alertInfo, "There shouldn't be an alert when the screen loads.")
// When entering a username for an unsupported homeserver.
let deferred = deferFulfillment(context.observe(\.viewState.bindings.alertInfo)) { $0 != nil }
context.username = "@bob:secure.gov"
context.send(viewAction: .parseUsername)
try await deferred.fulfill()
// Then the view state should be updated to show an alert.
XCTAssertEqual(context.alertInfo?.id, .elementProAlert, "An alert should be shown to the user.")
}
func testLoginHint() async throws {
await setupViewModel(loginHint: "")
XCTAssertEqual(context.username, "")
@@ -172,6 +187,7 @@ class LoginScreenViewModelTests: XCTestCase {
viewModel = LoginScreenViewModel(authenticationService: service,
loginHint: loginHint,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics)
}
}
@@ -196,6 +196,23 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
XCTAssertEqual(context.alertInfo?.id, .login)
}
func testElementProRequired() async throws {
// Given a view model for login using a service that hasn't been configured and the default server requires Element Pro.
setupViewModel(authenticationFlow: .login, supportsOIDC: false, supportsOIDCCreatePrompt: false, supportsPasswordLogin: false, requiresElementPro: true)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertNil(context.alertInfo)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the configuration should fail with an alert telling the user to download Element Pro.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(context.alertInfo?.id, .elementProRequired(serverName: "matrix.org"))
}
// MARK: - Picker mode
func testPickerWithoutConfiguration() async throws {
@@ -288,7 +305,8 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
supportsOIDC: Bool = true,
supportsOIDCCreatePrompt: Bool = true,
supportsPasswordLogin: Bool = true,
restrictedFlow: Bool = false) {
restrictedFlow: Bool = false,
requiresElementPro: Bool = false) {
var mode = ServerConfirmationScreenMode.confirmation("matrix.org")
if restrictedFlow {
appSettings.override(accountProviders: ["matrix.org", "beta.matrix.org"],
@@ -315,7 +333,8 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Manually create a configuration as the default homeserver address setting is immutable.
client = ClientSDKMock(configuration: .init(oidcLoginURL: supportsOIDC ? "https://account.matrix.org/authorize" : nil,
supportsOIDCCreatePrompt: supportsOIDCCreatePrompt,
supportsPasswordLogin: supportsPasswordLogin))
supportsPasswordLogin: supportsPasswordLogin,
elementWellKnown: requiresElementPro ? "{\"version\":1,\"enforce_element_pro\":true}" : nil))
let configuration = AuthenticationClientFactoryMock.Configuration(homeserverClients: ["matrix.org": client])
clientFactory = AuthenticationClientFactoryMock(configuration: configuration)
@@ -328,6 +347,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
viewModel = ServerConfirmationScreenViewModel(authenticationService: service,
mode: mode,
authenticationFlow: authenticationFlow,
appSettings: ServiceLocator.shared.settings,
userIndicatorController: UserIndicatorControllerMock())
// Add a fake window in order for the OIDC flow to continue
@@ -87,6 +87,24 @@ class ServerSelectionScreenViewModelTests: XCTestCase {
XCTAssertEqual(context.alertInfo?.id, .registrationAlert)
}
func testElementProRequiredAlert() async throws {
// Given a view model for login.
setupViewModel(authenticationFlow: .login)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertNil(context.alertInfo)
// When selecting a server that requires Element Pro
context.homeserverAddress = "secure.gov"
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then selection should fail with an alert telling the user to download Element Pro.
XCTAssertEqual(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(context.alertInfo?.id, .elementProAlert)
}
func testInvalidServer() async throws {
// Given a new instance of the view model.
setupViewModel(authenticationFlow: .login)
@@ -131,6 +149,7 @@ class ServerSelectionScreenViewModelTests: XCTestCase {
viewModel = ServerSelectionScreenViewModel(authenticationService: service,
authenticationFlow: authenticationFlow,
appSettings: ServiceLocator.shared.settings,
userIndicatorController: UserIndicatorControllerMock())
}
}