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