From 3cb815f7d429a013cf2451b79fdda9fa39dad90d Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Thu, 17 Jul 2025 13:41:26 +0100 Subject: [PATCH] Show an alert when entering an account provider that requires Element Pro. (#4326) --- ElementX.xcodeproj/project.pbxproj | 8 +++ .../en.lproj/Localizable.strings | 3 ++ ElementX/Sources/AppHooks/AppHooks.swift | 5 ++ .../AppHooks/Hooks/ElementWellKnownHook.swift | 53 +++++++++++++++++++ .../Sources/Application/AppSettings.swift | 3 ++ .../AuthenticationFlowCoordinator.swift | 2 + ElementX/Sources/Generated/Strings.swift | 8 +++ ...thenticationClientBuilderFactoryMock.swift | 11 +++- .../Sources/Mocks/SDK/ClientSDKMock.swift | 9 ++++ .../LoginScreen/LoginScreenCoordinator.swift | 2 + .../LoginScreen/LoginScreenModels.swift | 2 + .../LoginScreen/LoginScreenViewModel.swift | 13 +++++ .../LoginScreen/View/LoginScreen.swift | 1 + .../ServerConfirmationScreenCoordinator.swift | 1 + .../ServerConfirmationScreenModels.swift | 2 + .../ServerConfirmationScreenViewModel.swift | 13 +++++ .../View/ServerConfirmationScreen.swift | 1 + .../ServerSelectionScreenCoordinator.swift | 2 + .../ServerSelectionScreenModels.swift | 2 + .../ServerSelectionScreenViewModel.swift | 11 ++++ .../View/ServerSelectionScreen.swift | 1 + .../AuthenticationService.swift | 6 +++ .../AuthenticationServiceProtocol.swift | 1 + ElementX/Sources/Services/Client/Client.swift | 35 ++++++++++++ .../Services/Client/ClientProxyProtocol.swift | 1 + .../UITests/UITestsAppCoordinator.swift | 1 + Enterprise | 2 +- .../Sources/LoginScreenViewModelTests.swift | 16 ++++++ ...rverConfirmationScreenViewModelTests.swift | 24 ++++++++- .../ServerSelectionScreenViewModelTests.swift | 19 +++++++ 30 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 ElementX/Sources/AppHooks/Hooks/ElementWellKnownHook.swift create mode 100644 ElementX/Sources/Services/Client/Client.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a3f11e434..395172b35 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 011AFA4990C585D157829679 /* DeclineAndBlockScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenViewModel.swift; sourceTree = ""; }; 012A284622B32052015F1F89 /* ReadMarkerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineView.swift; sourceTree = ""; }; + 0142FAE169CC4619D8A30262 /* ElementWellKnownHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementWellKnownHook.swift; sourceTree = ""; }; 018194CAFBE80720FECCEDEE /* ZoomTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomTransition.swift; sourceTree = ""; }; 01B795AAAB7B8747FE2FF311 /* LogViewerScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenModels.swift; sourceTree = ""; }; 01C4C7DB37597D7D8379511A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -1435,6 +1438,7 @@ 06B098A612DCB5A7358EECD5 /* DeveloperOptionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenModels.swift; sourceTree = ""; }; 06F27F588F9059128E17C669 /* WindowManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManagerProtocol.swift; sourceTree = ""; }; 06FAE373A7F20780BA84B59C /* MessageForwardingScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenCoordinator.swift; sourceTree = ""; }; + 0724EBDFE8BB4C9E5547C57D /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; 07579F9C29001E40715F3014 /* NotificationSettingsChatType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsChatType.swift; sourceTree = ""; }; 077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfo.swift; sourceTree = ""; }; 077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorStateMachine.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index dda873b02..84aaffb3f 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -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$@."; diff --git a/ElementX/Sources/AppHooks/AppHooks.swift b/ElementX/Sources/AppHooks/AppHooks.swift index cd2ac538e..9ff6781b1 100644 --- a/ElementX/Sources/AppHooks/AppHooks.swift +++ b/ElementX/Sources/AppHooks/AppHooks.swift @@ -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 diff --git a/ElementX/Sources/AppHooks/Hooks/ElementWellKnownHook.swift b/ElementX/Sources/AppHooks/Hooks/ElementWellKnownHook.swift new file mode 100644 index 000000000..63f5fc933 --- /dev/null +++ b/ElementX/Sources/AppHooks/Hooks/ElementWellKnownHook.swift @@ -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 +} + +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 { + 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) +} diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 79b8040f5..1cc615ea2 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -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 diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index 4048de6fc..f74909085 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 914820026..e44d0cb08 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -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: diff --git a/ElementX/Sources/Mocks/AuthenticationClientBuilderFactoryMock.swift b/ElementX/Sources/Mocks/AuthenticationClientBuilderFactoryMock.swift index 87195c65d..0f1eb25e5 100644 --- a/ElementX/Sources/Mocks/AuthenticationClientBuilderFactoryMock.swift +++ b/ElementX/Sources/Mocks/AuthenticationClientBuilderFactoryMock.swift @@ -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}")) ] } diff --git a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift index 67a08cb97..74ddfdda6 100644 --- a/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift +++ b/ElementX/Sources/Mocks/SDK/ClientSDKMock.swift @@ -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 + } + } } } diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift index de41ef7ce..77209c0dc 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenCoordinator.swift @@ -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) } diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenModels.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenModels.swift index f31c530f7..87623526c 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenModels.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenModels.swift @@ -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. diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift index 84c54dfb4..dcb665e0c 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginScreenViewModel.swift @@ -13,6 +13,7 @@ typealias LoginScreenViewModelType = StateStoreViewModelV2 = .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, diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift index 6bae21a50..a52661118 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/View/LoginScreen.swift @@ -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 { diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenCoordinator.swift index 83c1efed1..2784ba153 100644 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenCoordinator.swift @@ -40,6 +40,7 @@ final class ServerConfirmationScreenCoordinator: CoordinatorProtocol { viewModel = ServerConfirmationScreenViewModel(authenticationService: parameters.authenticationService, mode: mode, authenticationFlow: parameters.authenticationFlow, + appSettings: parameters.appSettings, userIndicatorController: parameters.userIndicatorController) } diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenModels.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenModels.swift index 471ea6fef..f5d59b12a 100644 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenModels.swift +++ b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenModels.swift @@ -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 } diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift index 830f701c2..e4dfc7a3d 100644 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/ServerConfirmationScreenViewModel.swift @@ -13,6 +13,7 @@ typealias ServerConfirmationScreenViewModelType = StateStoreViewModelV2 = .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) } diff --git a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/View/ServerConfirmationScreen.swift b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/View/ServerConfirmationScreen.swift index caa3a605a..3be766514 100644 --- a/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/View/ServerConfirmationScreen.swift +++ b/ElementX/Sources/Screens/Authentication/ServerConfirmationScreen/View/ServerConfirmationScreen.swift @@ -162,6 +162,7 @@ struct ServerConfirmationScreen_Previews: PreviewProvider, TestablePreview { ServerConfirmationScreenViewModel(authenticationService: AuthenticationService.mock, mode: mode, authenticationFlow: flow, + appSettings: ServiceLocator.shared.settings, userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenCoordinator.swift index 09ff58525..56527313d 100644 --- a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenCoordinator.swift @@ -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 } diff --git a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenModels.swift b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenModels.swift index 1e6552021..0052e935f 100644 --- a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenModels.swift +++ b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenModels.swift @@ -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 } diff --git a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift index 02deac254..7bcd8d045 100644 --- a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift @@ -13,6 +13,7 @@ typealias ServerSelectionScreenViewModelType = StateStoreViewModelV2 = .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) } diff --git a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/View/ServerSelectionScreen.swift b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/View/ServerSelectionScreen.swift index 560f12074..cd6ac7d0f 100644 --- a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/View/ServerSelectionScreen.swift +++ b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/View/ServerSelectionScreen.swift @@ -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" { diff --git a/ElementX/Sources/Services/Authentication/AuthenticationService.swift b/ElementX/Sources/Services/Authentication/AuthenticationService.swift index cca842e68..dfcfc16f9 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationService.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationService.swift @@ -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)) diff --git a/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift b/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift index a844a6e62..30c793df4 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift @@ -30,6 +30,7 @@ enum AuthenticationServiceError: Error, Equatable { case slidingSyncNotAvailable case loginNotSupported case registrationNotSupported + case elementProRequired(serverName: String) case accountDeactivated case failedLoggingIn case sessionTokenRefreshNotSupported diff --git a/ElementX/Sources/Services/Client/Client.swift b/ElementX/Sources/Services/Client/Client.swift new file mode 100644 index 000000000..116d5402a --- /dev/null +++ b/ElementX/Sources/Services/Client/Client.swift @@ -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 { + 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)) + } + } +} diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 695d97dd9..3590da999 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -34,6 +34,7 @@ enum ClientProxyError: Error { case invalidMedia case invalidServerName + case invalidResponse case failedUploadingMedia(ErrorKind) case roomPreviewIsPrivate case failedRetrievingUserIdentity diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index fd62cad48..5d457b5e8 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -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 diff --git a/Enterprise b/Enterprise index 233f49aa2..245f823e9 160000 --- a/Enterprise +++ b/Enterprise @@ -1 +1 @@ -Subproject commit 233f49aa2063ef7113007e7ea6a90225be903e5e +Subproject commit 245f823e92296bace1dcef31f358025b4dd7a4a9 diff --git a/UnitTests/Sources/LoginScreenViewModelTests.swift b/UnitTests/Sources/LoginScreenViewModelTests.swift index c95b4f7d8..776cdbb05 100644 --- a/UnitTests/Sources/LoginScreenViewModelTests.swift +++ b/UnitTests/Sources/LoginScreenViewModelTests.swift @@ -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) } } diff --git a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift index 461871d75..2aa7e0da0 100644 --- a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift +++ b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift @@ -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 diff --git a/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift index bf55264f6..4298e7ccd 100644 --- a/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift +++ b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift @@ -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()) } }