Add Email/Terms/ReCaptcha into the Authentication flow

Replace ReCaptcha navigation delegate with a WKUserContentController.
Move callback property closures onto the MainActor.
Show a loading indicator whilst waiting for the authentication service to start.
Move nextUncompletedStage into FlowResult.
Handle text field actions during authentication.
Remove scroll view tweaks in server selection screen following EMS banner removal.
This commit is contained in:
Doug 2022-05-12 17:40:36 +01:00 committed by Doug
parent 196a889f9a
commit b9b4d18124
42 changed files with 635 additions and 263 deletions

View file

@ -36,6 +36,8 @@
"authentication_server_selection_server_footer" = "You can only connect to a server that has already been set up";
"authentication_server_selection_generic_error" = "Cannot find a server at this URL, please check it is correct.";
"authentication_cancel_flow_confirmation_message" = "Your account is not created yet. Stop the registration process?";
"authentication_verify_email_input_title" = "Enter your email address";
"authentication_verify_email_input_message" = "This will help verify your account and enables password recovery.";
"authentication_verify_email_text_field_placeholder" = "Email Address";
@ -46,6 +48,7 @@
"authentication_terms_title" = "Privacy policy";
"authentication_terms_message" = "Please read through T&C. You must accept in order to continue.";
"authentication_terms_policy_url_error" = "Unable to find the selected policy. Please try again later.";
"authentication_recaptcha_message" = "This server would like to make sure you are not a robot";

View file

@ -10,6 +10,10 @@ import Foundation
// swiftlint:disable function_parameter_count identifier_name line_length type_body_length
public extension VectorL10n {
/// Your account is not created yet. Stop the registration process?
static var authenticationCancelFlowConfirmationMessage: String {
return VectorL10n.tr("Untranslated", "authentication_cancel_flow_confirmation_message")
}
/// This server would like to make sure you are not a robot
static var authenticationRecaptchaMessage: String {
return VectorL10n.tr("Untranslated", "authentication_recaptcha_message")
@ -70,6 +74,10 @@ public extension VectorL10n {
static var authenticationTermsMessage: String {
return VectorL10n.tr("Untranslated", "authentication_terms_message")
}
/// Unable to find the selected policy. Please try again later.
static var authenticationTermsPolicyUrlError: String {
return VectorL10n.tr("Untranslated", "authentication_terms_policy_url_error")
}
/// Privacy policy
static var authenticationTermsTitle: String {
return VectorL10n.tr("Untranslated", "authentication_terms_title")

View file

@ -24,11 +24,13 @@ enum AuthenticationCoordinatorResult {
case didLogin(session: MXSession, authenticationFlow: AuthenticationFlow, authenticationType: AuthenticationType)
/// All of the required authentication steps including key verification is complete.
case didComplete
/// The user has cancelled the associated authentication flow.
case cancel(AuthenticationFlow)
}
/// `AuthenticationCoordinatorProtocol` is a protocol describing a Coordinator that handle's the authentication navigation flow.
protocol AuthenticationCoordinatorProtocol: Coordinator, Presentable {
var completion: ((AuthenticationCoordinatorResult) -> Void)? { get set }
var callback: ((AuthenticationCoordinatorResult) -> Void)? { get set }
/// Whether the custom homeserver checkbox is enabled for the user to enter a homeserver URL.
var customServerFieldsVisible: Bool { get set }

View file

@ -45,7 +45,7 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((AuthenticationCoordinatorResult) -> Void)?
var callback: ((AuthenticationCoordinatorResult) -> Void)?
var customServerFieldsVisible = false {
didSet {
@ -143,7 +143,7 @@ final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinator
}
private func authenticationDidComplete() {
completion?(.didComplete)
callback?(.didComplete)
}
}
@ -195,9 +195,9 @@ extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate
authenticationType = .other
}
completion?(.didLogin(session: session,
authenticationFlow: authenticationViewController.authType.flow,
authenticationType: authenticationType))
callback?(.didLogin(session: session,
authenticationFlow: authenticationViewController.authType.flow,
authenticationType: authenticationType))
}
}

View file

@ -18,7 +18,6 @@ import UIKit
import PhotosUI
import CommonKit
@available(iOS 14.0, *)
protocol MediaPickerPresenterDelegate: AnyObject {
func mediaPickerPresenter(_ presenter: MediaPickerPresenter, didPickImage image: UIImage)
func mediaPickerPresenterDidCancel(_ presenter: MediaPickerPresenter)
@ -26,10 +25,6 @@ protocol MediaPickerPresenterDelegate: AnyObject {
/// A picker for photos and videos from the user's photo library on iOS 14+ using the
/// new `PHPickerViewController` that doesn't require permission to be granted.
///
/// **Note:** If you need to support iOS 12 & 13, then you will need to use the older
/// `MediaPickerCoordinator`/`MediaPickerViewController` instead.
@available(iOS 14.0, *)
final class MediaPickerPresenter: NSObject {
// MARK: - Properties
@ -69,11 +64,11 @@ final class MediaPickerPresenter: NSObject {
// MARK: - Private
func showLoadingIndicator() {
private func showLoadingIndicator() {
loadingIndicator = indicatorPresenter?.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
func hideLoadingIndicator() {
private func hideLoadingIndicator() {
loadingIndicator = nil
}
}

View file

@ -57,7 +57,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((AuthenticationCoordinatorResult) -> Void)?
var callback: ((AuthenticationCoordinatorResult) -> Void)?
// MARK: - Setup
@ -72,26 +72,30 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
// MARK: - Public
func start() {
Task {
do {
let flow: AuthenticationFlow = initialScreen == .login ? .login : .register
try await authenticationService.startFlow(flow, for: authenticationService.state.homeserver.address)
} catch {
MXLog.error("[AuthenticationCoordinator] start: Failed to start")
await MainActor.run { displayError(error) }
return
}
await MainActor.run {
switch initialScreen {
case .registration:
showRegistrationScreen()
case .selectServerForRegistration:
showServerSelectionScreen()
case .login:
showLoginScreen()
}
}
Task { await startAsync() }
}
/// An async version of `start`.
///
/// Allows the caller to show an activity indicator until the authentication service is ready.
@MainActor func startAsync() async {
do {
let flow: AuthenticationFlow = initialScreen == .login ? .login : .register
let homeserverAddress = authenticationService.state.homeserver.addressFromUser ?? authenticationService.state.homeserver.address
try await authenticationService.startFlow(flow, for: homeserverAddress)
} catch {
MXLog.error("[AuthenticationCoordinator] start: Failed to start")
displayError(error)
return
}
switch initialScreen {
case .registration:
showRegistrationScreen()
case .selectServerForRegistration:
showServerSelectionScreen()
case .login:
showLoginScreen()
}
}
@ -99,7 +103,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
navigationRouter.toPresentable()
}
func presentPendingScreensIfNecessary() {
@MainActor func presentPendingScreensIfNecessary() {
canPresentAdditionalScreens = true
showLoadingAnimation()
@ -113,7 +117,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
// MARK: - Private
/// Presents an alert on top of the navigation router, using the supplied error's `localizedDescription`.
@MainActor func displayError(_ error: Error) {
@MainActor private func displayError(_ error: Error) {
let alert = UIAlertController(title: VectorL10n.error,
message: error.localizedDescription,
preferredStyle: .alert)
@ -123,6 +127,26 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
toPresentable().present(alert, animated: true)
}
/// Prompts the user to confirm that they would like to cancel the registration flow.
@MainActor private func displayCancelConfirmation() {
let alert = UIAlertController(title: VectorL10n.warning,
message: VectorL10n.authenticationCancelFlowConfirmationMessage,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: VectorL10n.no, style: .cancel))
alert.addAction(UIAlertAction(title: VectorL10n.yes, style: .default) { [weak self] _ in
self?.cancelRegistration()
})
toPresentable().present(alert, animated: true)
}
/// Cancels the registration flow, handing control back to the onboarding coordinator.
@MainActor private func cancelRegistration() {
authenticationService.reset()
callback?(.cancel(.register))
}
// MARK: - Registration
/// Pushes the server selection screen into the flow (other screens may also present it modally later).
@ -140,7 +164,9 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
add(childCoordinator: coordinator)
if navigationRouter.modules.isEmpty {
navigationRouter.setRootModule(coordinator, popCompletion: nil)
navigationRouter.setRootModule(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
} else {
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
@ -194,6 +220,87 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
}
}
/// Shows the verify email screen.
@MainActor private func showVerifyEmailScreen() {
MXLog.debug("[AuthenticationCoordinator] showVerifyEmailScreen")
guard let registrationWizard = authenticationService.registrationWizard else { fatalError("Handle these errors more gracefully.") }
let parameters = AuthenticationVerifyEmailCoordinatorParameters(registrationWizard: registrationWizard)
let coordinator = AuthenticationVerifyEmailCoordinator(parameters: parameters)
coordinator.callback = { [weak self] result in
self?.registrationStageDidComplete(with: result)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Shows the terms screen.
@MainActor private func showTermsScreen(terms: MXLoginTerms?) {
MXLog.debug("[AuthenticationCoordinator] showTermsScreen")
guard let registrationWizard = authenticationService.registrationWizard else { fatalError("Handle these errors more gracefully.") }
let homeserver = authenticationService.state.homeserver
let localizedPolicies = terms?.policiesData(forLanguage: Bundle.mxk_language(), defaultLanguage: "en")
let parameters = AuthenticationTermsCoordinatorParameters(registrationWizard: registrationWizard,
localizedPolicies: localizedPolicies ?? [],
homeserverAddress: homeserver.addressFromUser ?? homeserver.address)
let coordinator = AuthenticationTermsCoordinator(parameters: parameters)
coordinator.callback = { [weak self] result in
self?.registrationStageDidComplete(with: result)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
@MainActor private func showReCaptchaScreen(siteKey: String) {
MXLog.debug("[AuthenticationCoordinator] showReCaptchaScreen")
guard
let registrationWizard = authenticationService.registrationWizard,
let homeserverURL = URL(string: authenticationService.state.homeserver.address)
else { fatalError("Handle these errors more gracefully.") }
let parameters = AuthenticationReCaptchaCoordinatorParameters(registrationWizard: registrationWizard,
siteKey: siteKey,
homeserverURL: homeserverURL)
let coordinator = AuthenticationReCaptchaCoordinator(parameters: parameters)
coordinator.callback = { [weak self] result in
self?.registrationStageDidComplete(with: result)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Shows the verify email screen.
@MainActor private func showVerifyMSISDNScreen() {
MXLog.debug("[AuthenticationCoordinator] showVerifyMSISDNScreen")
fatalError("Phone verification not implemented yet.")
}
/// Displays the next view in the registration flow.
@MainActor private func registrationStageDidComplete(with result: AuthenticationRegistrationStageResult) {
switch result {
case .completed(let result):
handleRegistrationResult(result)
case .cancel:
displayCancelConfirmation()
}
}
/// Shows the login screen.
@MainActor private func showLoginScreen() {
MXLog.debug("[AuthenticationCoordinator] showLoginScreen")
@ -202,18 +309,42 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
// MARK: - Registration Handlers
/// Determines the next screen to show from the flow result and pushes it.
func handleRegistrationResult(_ result: RegistrationResult) {
@MainActor private func handleRegistrationResult(_ result: RegistrationResult) {
switch result {
case .success(let mxSession):
onSessionCreated(session: mxSession, flow: .register)
case .flowResponse(let flowResult):
// TODO
break
MXLog.debug("[AuthenticationCoordinator] handleRegistrationResult: Missing stages - \(flowResult.missingStages)")
guard let nextStage = flowResult.nextUncompletedStage else {
MXLog.failure("[AuthenticationCoordinator] There are no remaining stages.")
return
}
showStage(nextStage)
}
}
@MainActor private func showStage(_ stage: FlowResult.Stage) {
switch stage {
case .reCaptcha(_, let siteKey):
showReCaptchaScreen(siteKey: siteKey)
case .email:
showVerifyEmailScreen()
case .msisdn:
showVerifyMSISDNScreen()
case .dummy:
MXLog.failure("[AuthenticationCoordinator] Attempting to perform the dummy stage.")
case .terms(_, let terms):
showTermsScreen(terms: terms)
case .other:
#warning("Show fallback")
MXLog.failure("[AuthenticationCoordinator] Attempting to perform an unsupported stage.")
}
}
/// Handles the creation of a new session following on from a successful authentication.
func onSessionCreated(session: MXSession, flow: AuthenticationFlow) {
@MainActor private func onSessionCreated(session: MXSession, flow: AuthenticationFlow) {
self.session = session
// self.password = password
@ -244,7 +375,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
self.verificationListener = verificationListener
#warning("Add authentication type to the new flow")
completion?(.didLogin(session: session, authenticationFlow: flow, authenticationType: .other))
callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: .other))
}
// MARK: - Additional Screens
@ -281,7 +412,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
/// Complete the authentication flow.
private func authenticationDidComplete() {
completion?(.didComplete)
callback?(.didComplete)
}
}

View file

@ -147,7 +147,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator, popCompletion: nil)
navigationRouter.setRootModule(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Displays the next view in the flow after the splash screen.
@ -166,7 +168,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
}
/// Show the use case screen for new users.
private func showUseCaseSelectionScreen() {
private func showUseCaseSelectionScreen(animated: Bool = true) {
MXLog.debug("[OnboardingCoordinator] showUseCaseSelectionScreen")
let coordinator = OnboardingUseCaseSelectionCoordinator()
@ -179,9 +181,11 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
add(childCoordinator: coordinator)
if navigationRouter.modules.isEmpty {
navigationRouter.setRootModule(coordinator, popCompletion: nil)
navigationRouter.setRootModule(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
} else {
navigationRouter.push(coordinator, animated: true) { [weak self] in
navigationRouter.push(coordinator, animated: animated) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
@ -193,27 +197,32 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
guard BuildSettings.onboardingEnableNewAuthenticationFlow else {
showLegacyAuthenticationScreen()
coordinator.stop()
return
}
if result == .customServer {
beginAuthentication(with: .selectServerForRegistration)
} else {
beginAuthentication(with: .registration)
Task {
if result == .customServer {
await beginAuthentication(with: .selectServerForRegistration)
} else {
await beginAuthentication(with: .registration)
}
coordinator.stop()
}
}
// MARK: - Authentication
/// Show the authentication flow, starting at the specified initial screen.
private func beginAuthentication(with initialScreen: AuthenticationCoordinator.EntryPoint) {
private func beginAuthentication(with initialScreen: AuthenticationCoordinator.EntryPoint) async {
MXLog.debug("[OnboardingCoordinator] beginAuthentication")
let parameters = AuthenticationCoordinatorParameters(navigationRouter: navigationRouter,
initialScreen: initialScreen,
canPresentAdditionalScreens: false)
let coordinator = AuthenticationCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] result in
coordinator.callback = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return }
switch result {
@ -221,11 +230,13 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
self.authenticationCoordinator(coordinator, didLoginWith: session, and: authenticationFlow, using: authenticationType)
case .didComplete:
self.authenticationCoordinatorDidComplete(coordinator)
case .cancel(let flow):
self.cancelAuthentication(flow: flow)
}
}
add(childCoordinator: coordinator)
coordinator.start()
await coordinator.startAsync()
}
/// Show the legacy authentication screen. Any parameters that have been set in previous screens are be applied.
@ -235,7 +246,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
MXLog.debug("[OnboardingCoordinator] showLegacyAuthenticationScreen")
let coordinator = authenticationCoordinator
coordinator.completion = { [weak self, weak coordinator] result in
coordinator.callback = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return }
switch result {
@ -243,6 +254,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
self.authenticationCoordinator(coordinator, didLoginWith: session, and: authenticationFlow, using: authenticationType)
case .didComplete:
self.authenticationCoordinatorDidComplete(coordinator)
case .cancel:
// Cancellation is only part of the new flow.
break
}
}
@ -278,6 +292,20 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
isShowingLegacyAuthentication = true
}
/// Cancels the registration flow, returning to the Use Case screen.
private func cancelAuthentication(flow: AuthenticationFlow) {
switch flow {
case .register:
navigationRouter.popAllModules(animated: false)
showSplashScreen()
showUseCaseSelectionScreen(animated: false)
case .login:
// Probably not needed, error for now until the new login flow is implemented.
MXLog.failure("[OnboardingCoordinator] cancelAuthentication: Not implemented for the login flow")
}
}
/// Displays the next view in the flow after the authentication screens,
/// whilst crypto and the rest of the app is launching in the background.
private func authenticationCoordinator(_ coordinator: AuthenticationCoordinatorProtocol,

View file

@ -58,6 +58,7 @@ enum RegistrationError: String, LocalizedError {
case missingThreePIDURL
case threePIDValidationFailure
case threePIDClientFailure
case waitingForThreePIDValidation
var errorDescription: String? {
switch self {

View file

@ -138,7 +138,8 @@ class AuthenticationService: NSObject {
registrationWizard = nil
// The previously used homeserver is re-used as `startFlow` will be called again a replace it anyway.
self.state = AuthenticationState(flow: .login, homeserverAddress: state.homeserver.address)
let address = state.homeserver.addressFromUser ?? state.homeserver.address
self.state = AuthenticationState(flow: .login, homeserverAddress: address)
}
/// Create a session after a SSO successful login

View file

@ -101,6 +101,14 @@ struct AuthenticationParameters: Codable {
}
}
/// The result from a registration screen's coordinator
enum AuthenticationRegistrationStageResult {
/// The screen completed with the associated registration result.
case completed(RegistrationResult)
/// The user would like to cancel the registration.
case cancel
}
/// The result from a response of a registration flow step.
enum RegistrationResult {
/// Registration has completed, creating an `MXSession` for the account.
@ -119,30 +127,73 @@ struct FlowResult {
/// A stage in the authentication flow.
enum Stage {
/// The stage with the type `m.login.recaptcha`.
case reCaptcha(mandatory: Bool, publicKey: String)
case reCaptcha(isMandatory: Bool, siteKey: String)
/// The stage with the type `m.login.email.identity`.
case email(mandatory: Bool)
case email(isMandatory: Bool)
/// The stage with the type `m.login.msisdn`.
case msisdn(mandatory: Bool)
case msisdn(isMandatory: Bool)
/// The stage with the type `m.login.dummy`.
///
/// This stage can be mandatory if there is no other stages. In this case the account cannot
/// be created by just sending a username and a password, the dummy stage has to be completed.
case dummy(mandatory: Bool)
case dummy(isMandatory: Bool)
/// The stage with the type `m.login.terms`.
case terms(mandatory: Bool, policies: [AnyHashable: Any])
case terms(isMandatory: Bool, terms: MXLoginTerms?)
/// A stage of an unknown type.
case other(mandatory: Bool, type: String, params: [AnyHashable: Any])
case other(isMandatory: Bool, type: String, params: [AnyHashable: Any])
/// Whether the stage is a dummy stage that is also mandatory.
var isDummyAndMandatory: Bool {
guard case let .dummy(isMandatory) = self else { return false }
return isMandatory
/// Whether the stage is mandatory.
var isMandatory: Bool {
switch self {
case .reCaptcha(let isMandatory, _):
return isMandatory
case .email(let isMandatory):
return isMandatory
case .msisdn(let isMandatory):
return isMandatory
case .dummy(let isMandatory):
return isMandatory
case .terms(let isMandatory, _):
return isMandatory
case .other(let isMandatory, _, _):
return isMandatory
}
}
/// Whether the stage is the dummy stage.
var isDummy: Bool {
guard case .dummy = self else { return false }
return true
}
}
/// Determines the next stage to be completed in the flow.
var nextUncompletedStage: Stage? {
if let emailStage = missingStages.first(where: { if case .email = $0 { return true } else { return false } }) {
return emailStage
}
if let termsStage = missingStages.first(where: { if case .terms = $0 { return true } else { return false } }) {
return termsStage
}
if let reCaptchaStage = missingStages.first(where: { if case .reCaptcha = $0 { return true } else { return false } }) {
return reCaptchaStage
}
if let msisdnStage = missingStages.first(where: { if case .msisdn = $0 { return true } else { return false } }) {
return msisdnStage
}
MXLog.failure("[FlowResult.Stage] nextUncompletedStage: The dummy stage should be handled silently and any other stages should trigger the fallback flow.")
return missingStages.first
}
var needsFallback : Bool {
missingStages.filter { $0.isMandatory }.contains { stage in
if case .other = stage { return true } else { return false }
}
}
}
@ -150,7 +201,7 @@ struct FlowResult {
extension MXAuthenticationSession {
/// The flows from the session mapped as a `FlowResult` value.
var flowResult: FlowResult {
let allFlowTypes = Set(flows.flatMap { $0.stages ?? [] })
let allFlowTypes = Set(flows.flatMap { $0.stages ?? [] }) // Using a Set here loses the order, but an order is forced during presentation anyway.
var missingStages = [FlowResult.Stage]()
var completedStages = [FlowResult.Stage]()
@ -162,19 +213,20 @@ extension MXAuthenticationSession {
case kMXLoginFlowTypeRecaptcha:
let parameters = params[flow] as? [AnyHashable: Any]
let publicKey = parameters?["public_key"] as? String
stage = .reCaptcha(mandatory: isMandatory, publicKey: publicKey ?? "")
stage = .reCaptcha(isMandatory: isMandatory, siteKey: publicKey ?? "")
case kMXLoginFlowTypeDummy:
stage = .dummy(mandatory: isMandatory)
stage = .dummy(isMandatory: isMandatory)
case kMXLoginFlowTypeTerms:
let parameters = params[flow] as? [AnyHashable: Any]
stage = .terms(mandatory: isMandatory, policies: parameters ?? [:])
let terms = MXLoginTerms(fromJSON: parameters)
stage = .terms(isMandatory: isMandatory, terms: terms)
case kMXLoginFlowTypeMSISDN:
stage = .msisdn(mandatory: isMandatory)
stage = .msisdn(isMandatory: isMandatory)
case kMXLoginFlowTypeEmailIdentity:
stage = .email(mandatory: isMandatory)
stage = .email(isMandatory: isMandatory)
default:
let parameters = params[flow] as? [AnyHashable: Any]
stage = .other(mandatory: isMandatory, type: flow, params: parameters ?? [:])
stage = .other(isMandatory: isMandatory, type: flow, params: parameters ?? [:])
}
if let completed = completed, completed.contains(flow) {
@ -186,13 +238,4 @@ extension MXAuthenticationSession {
return FlowResult(missingStages: missingStages, completedStages: completedStages)
}
/// Determines the next stage to be completed in the flow.
func nextUncompletedStage(flowIndex: Int = 0) -> String? {
guard flows.count < flowIndex else { return nil }
return flows[flowIndex].stages.first {
guard let completed = completed else { return false }
return !completed.contains($0)
}
}
}

View file

@ -144,7 +144,7 @@ class RegistrationWizard {
/// Perform the "m.login.email.identity" or "m.login.msisdn" stage.
///
/// - Parameter threePID the threePID to add to the account. If this is an email, the homeserver will send an email
/// - Parameter threePID: the threePID to add to the account. If this is an email, the homeserver will send an email
/// to validate it. For a msisdn a SMS will be sent.
func addThreePID(threePID: RegisterThreePID) async throws -> RegistrationResult {
state.currentThreePIDData = nil
@ -168,15 +168,19 @@ class RegistrationWizard {
/// Useful to poll the homeserver when waiting for the email to be validated by the user.
/// Once the email is validated, this method will return successfully.
/// - Parameter delay How long to wait before sending the request.
func checkIfEmailHasBeenValidated(delay: TimeInterval) async throws -> RegistrationResult {
MXLog.failure("The delay on this method is no longer available. Move this to the object handling the polling.")
func checkIfEmailHasBeenValidated() async throws -> RegistrationResult {
guard let parameters = state.currentThreePIDData?.registrationParameters else {
MXLog.error("[RegistrationWizard] checkIfEmailHasBeenValidated: The current 3pid data hasn't been stored in the state.")
throw RegistrationError.missingThreePIDData
}
return try await performRegistrationRequest(parameters: parameters)
do {
return try await performRegistrationRequest(parameters: parameters)
} catch {
// An unauthorized error indicates that the user hasn't tapped the link yet.
guard isUnauthorized(error) else { throw error }
throw RegistrationError.waitingForThreePIDValidation
}
}
// MARK: - Private
@ -237,8 +241,14 @@ class RegistrationWizard {
state.currentThreePIDData = ThreePIDData(threePID: threePID, registrationResponse: response, registrationParameters: parameters)
// Send the session id for the first time
return try await performRegistrationRequest(parameters: parameters)
do {
// Send the session id for the first time
return try await performRegistrationRequest(parameters: parameters)
} catch {
// An unauthorized error means that it was accepted and is awaiting validation.
guard isUnauthorized(error) else { throw error }
throw RegistrationError.waitingForThreePIDValidation
}
}
private func performRegistrationRequest(parameters: RegistrationParameters, isCreatingAccount: Bool = false) async throws -> RegistrationResult {
@ -268,7 +278,13 @@ class RegistrationWizard {
/// Checks for a mandatory dummy stage and handles it automatically when possible.
private func handleMandatoryDummyStage(flowResult: FlowResult) async throws -> RegistrationResult {
// If the dummy stage is mandatory, do the dummy stage now
guard flowResult.missingStages.contains(where: { $0.isDummyAndMandatory }) else { return .flowResponse(flowResult) }
guard flowResult.missingStages.contains(where: { $0.isDummy && $0.isMandatory }) else { return .flowResponse(flowResult) }
return try await dummy()
}
/// Checks whether an error is an `M_UNAUTHORIZED` for handling third party ID responses.
private func isUnauthorized(_ error: Error) -> Bool {
guard let mxError = MXError(nsError: error) else { return false }
return mxError.errcode == kMXErrCodeStringUnauthorized
}
}

View file

@ -38,7 +38,7 @@ struct AuthenticationReCaptchaViewState: BindableState {
struct AuthenticationReCaptchaBindings {
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<Int>?
var alertInfo: AlertInfo<AuthenticationReCaptchaErrorType>?
}
enum AuthenticationReCaptchaViewAction {
@ -47,3 +47,10 @@ enum AuthenticationReCaptchaViewAction {
/// Cancel the flow.
case cancel
}
enum AuthenticationReCaptchaErrorType: Hashable {
/// An error response from the homeserver.
case mxError(String)
/// An unknown error occurred.
case unknown
}

View file

@ -28,7 +28,7 @@ class AuthenticationReCaptchaViewModel: AuthenticationReCaptchaViewModelType, Au
// MARK: Public
@MainActor var callback: ((AuthenticationReCaptchaViewModelResult) -> Void)?
var callback: (@MainActor (AuthenticationReCaptchaViewModelResult) -> Void)?
// MARK: - Setup
@ -47,4 +47,15 @@ class AuthenticationReCaptchaViewModel: AuthenticationReCaptchaViewModelType, Au
Task { await callback?(.validate(response)) }
}
}
@MainActor func displayError(_ type: AuthenticationReCaptchaErrorType) {
switch type {
case .mxError(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
message: message)
case .unknown:
state.bindings.alertInfo = AlertInfo(id: type)
}
}
}

View file

@ -18,6 +18,9 @@ import Foundation
protocol AuthenticationReCaptchaViewModelProtocol {
@MainActor var callback: ((AuthenticationReCaptchaViewModelResult) -> Void)? { get set }
var callback: (@MainActor (AuthenticationReCaptchaViewModelResult) -> Void)? { get set }
var context: AuthenticationReCaptchaViewModelType.Context { get }
/// Display an error to the user.
@MainActor func displayError(_ type: AuthenticationReCaptchaErrorType)
}

View file

@ -18,17 +18,11 @@ import SwiftUI
import CommonKit
struct AuthenticationReCaptchaCoordinatorParameters {
let authenticationService: AuthenticationService
let registrationWizard: RegistrationWizard
/// The ReCaptcha widget's site key.
let siteKey: String
}
enum AuthenticationReCaptchaCoordinatorResult {
/// The screen completed with the associated registration result.
case completed(RegistrationResult)
/// The user would like to cancel the registration.
case cancel
/// The homeserver URL, used for displaying the ReCaptcha.
let homeserverURL: URL
}
final class AuthenticationReCaptchaCoordinator: Coordinator, Presentable {
@ -38,7 +32,7 @@ final class AuthenticationReCaptchaCoordinator: Coordinator, Presentable {
// MARK: Private
private let parameters: AuthenticationReCaptchaCoordinatorParameters
private let authenticationReCaptchaHostingController: UIViewController
private let authenticationReCaptchaHostingController: VectorHostingController
private var authenticationReCaptchaViewModel: AuthenticationReCaptchaViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
@ -54,24 +48,22 @@ final class AuthenticationReCaptchaCoordinator: Coordinator, Presentable {
}
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
@MainActor var callback: ((AuthenticationReCaptchaCoordinatorResult) -> Void)?
var callback: (@MainActor (AuthenticationRegistrationStageResult) -> Void)?
// MARK: - Setup
@MainActor init(parameters: AuthenticationReCaptchaCoordinatorParameters) {
self.parameters = parameters
guard let homeserverURL = URL(string: parameters.authenticationService.state.homeserver.address) else {
fatalError()
}
let viewModel = AuthenticationReCaptchaViewModel(siteKey: parameters.siteKey, homeserverURL: homeserverURL)
let viewModel = AuthenticationReCaptchaViewModel(siteKey: parameters.siteKey, homeserverURL: parameters.homeserverURL)
let view = AuthenticationReCaptchaScreen(viewModel: viewModel.context)
authenticationReCaptchaViewModel = viewModel
authenticationReCaptchaHostingController = VectorHostingController(rootView: view)
authenticationReCaptchaHostingController.vc_removeBackTitle()
authenticationReCaptchaHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationReCaptchaHostingController)
}
@ -99,7 +91,7 @@ final class AuthenticationReCaptchaCoordinator: Coordinator, Presentable {
case .validate(let response):
self.performReCaptcha(response)
case .cancel:
#warning("Reset the flow")
self.callback?(.cancel)
}
}
}
@ -131,7 +123,20 @@ final class AuthenticationReCaptchaCoordinator: Coordinator, Presentable {
self?.stopLoading()
} catch {
self?.stopLoading()
self?.handleError(error)
}
}
}
/// Processes an error to either update the flow or display it to the user.
@MainActor private func handleError(_ error: Error) {
if let mxError = MXError(nsError: error as NSError) {
authenticationReCaptchaViewModel.displayError(.mxError(mxError.error))
return
}
// TODO: Handle any other error types as needed.
authenticationReCaptchaViewModel.displayError(.unknown)
}
}

View file

@ -51,6 +51,7 @@ struct AuthenticationReCaptchaScreen: View {
}
.navigationBarTitleDisplayMode(.inline)
.background(theme.colors.background.ignoresSafeArea())
.toolbar { toolbar }
.alert(item: $viewModel.alertInfo) { $0.alert }
.accentColor(theme.colors.accent)
}
@ -81,6 +82,15 @@ struct AuthenticationReCaptchaScreen: View {
viewModel.send(viewAction: .validate(response))
}
}
/// A simple toolbar with a cancel button.
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(VectorL10n.cancel) {
viewModel.send(viewAction: .cancel)
}
}
}
}
// MARK: - Previews

View file

@ -42,7 +42,13 @@ struct AuthenticationRecaptchaWebView: UIViewRepresentable {
// MARK: - Setup
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
let userContentController = WKUserContentController()
userContentController.add(context.coordinator, name: "recaptcha")
let configuration = WKWebViewConfiguration()
configuration.userContentController = userContentController
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
#if DEBUG
@ -66,7 +72,7 @@ struct AuthenticationRecaptchaWebView: UIViewRepresentable {
// MARK: - Coordinator
class Coordinator: NSObject, WKNavigationDelegate {
class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
/// The theme used to render the ReCaptcha
enum ReCaptchaTheme: String { case light, dark }
@ -90,13 +96,8 @@ struct AuthenticationRecaptchaWebView: UIViewRepresentable {
<style>@media (prefers-color-scheme: dark) { body { background-color: #15191E; } }</style>
<script type="text/javascript">
var verifyCallback = function(response) {
/* Generic method to make a bridge between JS and the WKWebView*/
var iframe = document.createElement('iframe');
iframe.setAttribute('src', 'js:' + JSON.stringify({'action': 'verifyCallback', 'response': response}));
document.documentElement.appendChild(iframe);
iframe.parentNode.removeChild(iframe);
iframe = null;
window.webkit.messageHandlers.recaptcha.postMessage(response);
alert('Testing 1234');
};
var onloadCallback = function() {
grecaptcha.render('recaptcha_widget', {
@ -128,24 +129,9 @@ struct AuthenticationRecaptchaWebView: UIViewRepresentable {
isLoading = false
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
guard
let url = navigationAction.request.url,
// Listen only to scheme of the JS-WKWebView bridge
navigationAction.request.url?.scheme == "js"
else { return .allow }
guard
let jsonString = url.path.removingPercentEncoding,
let jsonData = jsonString.data(using: .utf8),
let parameters = try? JSONSerialization.jsonObject(with: jsonData, options: .mutableContainers) as? [String: String],
parameters["action"] == "verifyCallback",
let response = parameters["response"]
else { return .cancel }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let response = message.body as? String else { return }
completion?(response)
return .cancel
}
}
}

View file

@ -27,7 +27,7 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy
// MARK: Public
@MainActor var callback: ((AuthenticationRegistrationViewModelResult) -> Void)?
@MainActor var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)?
// MARK: - Setup

View file

@ -18,7 +18,7 @@ import Foundation
protocol AuthenticationRegistrationViewModelProtocol {
@MainActor var callback: ((AuthenticationRegistrationViewModelResult) -> Void)? { get set }
@MainActor var callback: (@MainActor (AuthenticationRegistrationViewModelResult) -> Void)? { get set }
var context: AuthenticationRegistrationViewModelType.Context { get }
/// Update the view with new homeserver information.

View file

@ -194,7 +194,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
switch registrationError {
case .registrationDisabled:
authenticationRegistrationViewModel.displayError(.registrationDisabled)
case .createAccountNotCalled, .missingThreePIDData, .missingThreePIDURL, .threePIDClientFailure, .threePIDValidationFailure:
case .createAccountNotCalled, .missingThreePIDData, .missingThreePIDURL, .threePIDClientFailure, .threePIDValidationFailure, .waitingForThreePIDValidation:
// Shouldn't happen at this stage
authenticationRegistrationViewModel.displayError(.unknown)
}

View file

@ -24,6 +24,8 @@ struct AuthenticationRegistrationScreen: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@State private var isPasswordFocused = false
// MARK: Public
@ObservedObject var viewModel: AuthenticationRegistrationViewModel.Context
@ -129,10 +131,10 @@ struct AuthenticationRegistrationScreen: View {
footerText: viewModel.viewState.usernameFooterMessage,
isError: viewModel.viewState.hasEditedUsername && !viewModel.viewState.isUsernameValid,
isFirstResponder: false,
configuration: UIKitTextInputConfiguration(returnKeyType: .default,
configuration: UIKitTextInputConfiguration(returnKeyType: .next,
autocapitalizationType: .none,
autocorrectionType: .no),
onEditingChanged: { validateUsername(isEditing: $0) })
onEditingChanged: usernameEditingChanged)
.onChange(of: viewModel.username) { _ in viewModel.send(viewAction: .clearUsernameError) }
.accessibilityIdentifier("usernameTextField")
@ -141,12 +143,13 @@ struct AuthenticationRegistrationScreen: View {
text: $viewModel.password,
footerText: VectorL10n.authenticationRegistrationPasswordFooter,
isError: viewModel.viewState.hasEditedPassword && !viewModel.viewState.isPasswordValid,
isFirstResponder: false,
configuration: UIKitTextInputConfiguration(isSecureTextEntry: true),
onEditingChanged: { validatePassword(isEditing: $0) })
isFirstResponder: isPasswordFocused,
configuration: UIKitTextInputConfiguration(returnKeyType: .done,
isSecureTextEntry: true),
onEditingChanged: passwordEditingChanged)
.accessibilityIdentifier("passwordTextField")
Button { viewModel.send(viewAction: .next) } label: {
Button(action: submit) {
Text(VectorL10n.next)
}
.buttonStyle(PrimaryActionButtonStyle())
@ -167,17 +170,29 @@ struct AuthenticationRegistrationScreen: View {
}
}
/// Validates the username when the text field ends editing.
func validateUsername(isEditing: Bool) {
/// Validates the username when the text field ends editing, and selects the password text field.
func usernameEditingChanged(isEditing: Bool) {
guard !isEditing, !viewModel.username.isEmpty else { return }
viewModel.send(viewAction: .validateUsername)
isPasswordFocused = true
}
/// Enables password validation the first time the user finishes editing the password text field.
func validatePassword(isEditing: Bool) {
guard !viewModel.viewState.hasEditedPassword, !isEditing else { return }
/// Enables password validation the first time the user taps return, and sends the username and submits the form if possible.
func passwordEditingChanged(isEditing: Bool) {
guard !isEditing else { return }
isPasswordFocused = false
submit()
guard !viewModel.viewState.hasEditedPassword else { return }
viewModel.send(viewAction: .enablePasswordValidation)
}
/// Sends the `next` view action so long as valid credentials have been input.
func submit() {
guard viewModel.viewState.hasValidCredentials else { return }
viewModel.send(viewAction: .next)
}
}
// MARK: - Previews

View file

@ -28,7 +28,7 @@ class AuthenticationServerSelectionViewModel: AuthenticationServerSelectionViewM
// MARK: Public
@MainActor var callback: ((AuthenticationServerSelectionViewModelResult) -> Void)?
var callback: (@MainActor (AuthenticationServerSelectionViewModelResult) -> Void)?
// MARK: - Setup

View file

@ -18,7 +18,7 @@ import Foundation
protocol AuthenticationServerSelectionViewModelProtocol {
@MainActor var callback: ((AuthenticationServerSelectionViewModelResult) -> Void)? { get set }
var callback: (@MainActor (AuthenticationServerSelectionViewModelResult) -> Void)? { get set }
var context: AuthenticationServerSelectionViewModelType.Context { get }
/// Displays an error to the user.

View file

@ -48,7 +48,7 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable {
// Must be used only internally
var childCoordinators: [Coordinator] = []
@MainActor var callback: ((AuthenticationServerSelectionCoordinatorResult) -> Void)?
var callback: (@MainActor (AuthenticationServerSelectionCoordinatorResult) -> Void)?
// MARK: - Setup

View file

@ -50,7 +50,7 @@ class AuthenticationServerSelectionUITests: MockScreenTest {
XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.")
XCTAssertTrue(confirmButton.isEnabled, "The confirm button should be enabled when there is an address.")
let textFieldFooter = app.staticTexts["addressTextField"]
let textFieldFooter = app.staticTexts["textFieldFooter"]
XCTAssertTrue(textFieldFooter.exists)
XCTAssertEqual(textFieldFooter.label, VectorL10n.authenticationServerSelectionServerFooter)
@ -60,7 +60,7 @@ class AuthenticationServerSelectionUITests: MockScreenTest {
func verifyEmptyAddress() {
let serverTextField = app.textFields.element
XCTAssertEqual(serverTextField.value as? String, "", "The text field should be empty in this state.")
XCTAssertEqual(serverTextField.value as? String, VectorL10n.authenticationServerSelectionServerUrl, "The text field should show placeholder text in this state.")
let confirmButton = app.buttons["confirmButton"]
XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.")
@ -75,7 +75,7 @@ class AuthenticationServerSelectionUITests: MockScreenTest {
XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.")
XCTAssertFalse(confirmButton.isEnabled, "The confirm button should be disabled when there is an error.")
let textFieldFooter = app.staticTexts["addressTextField"]
let textFieldFooter = app.staticTexts["textFieldFooter"]
XCTAssertTrue(textFieldFooter.exists)
XCTAssertEqual(textFieldFooter.label, VectorL10n.errorCommonMessage)
}

View file

@ -17,10 +17,6 @@
import SwiftUI
struct AuthenticationServerSelectionScreen: View {
enum Constants {
static let textFieldID = "textFieldID"
}
// MARK: - Properties
@ -28,8 +24,11 @@ struct AuthenticationServerSelectionScreen: View {
@Environment(\.theme) private var theme
/// The scroll view proxy can be stored here for use in other methods.
@State private var scrollView: ScrollViewProxy?
@State private var isEditingTextField = false
private var textFieldFooterColor: Color {
viewModel.viewState.hasValidationError ? theme.colors.alert : theme.colors.tertiaryContent
}
// MARK: Public
@ -40,21 +39,17 @@ struct AuthenticationServerSelectionScreen: View {
var body: some View {
GeometryReader { geometry in
ScrollView {
ScrollViewReader { reader in
VStack(spacing: 0) {
header
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
.padding(.bottom, 36)
serverForm
}
.readableFrame()
.padding(.horizontal, 16)
.onAppear { scrollView = reader }
VStack(spacing: 0) {
header
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
.padding(.bottom, 36)
serverForm
}
.readableFrame()
.padding(.horizontal, 16)
}
}
.ignoresSafeArea(.keyboard)
.background(theme.colors.background.ignoresSafeArea())
.toolbar { toolbar }
.alert(item: $viewModel.alertInfo) { $0.alert }
@ -82,23 +77,22 @@ struct AuthenticationServerSelectionScreen: View {
/// The text field and confirm button where the user enters a server URL.
var serverForm: some View {
VStack(alignment: .leading, spacing: 12) {
RoundedBorderTextField(title: nil,
placeHolder: VectorL10n.authenticationServerSelectionServerUrl,
text: $viewModel.homeserverAddress,
footerText: viewModel.viewState.footerMessage,
isError: viewModel.viewState.isShowingFooterError,
isFirstResponder: false,
configuration: UIKitTextInputConfiguration(keyboardType: .URL,
returnKeyType: .default,
autocapitalizationType: .none,
autocorrectionType: .no),
onTextChanged: nil,
onEditingChanged: textFieldEditingChangeHandler)
.onChange(of: viewModel.homeserverAddress) { _ in viewModel.send(viewAction: .clearFooterError) }
.id(Constants.textFieldID)
.accessibilityIdentifier("addressTextField")
VStack(spacing: 8) {
if #available(iOS 15.0, *) {
textField
.onSubmit(submit)
} else {
textField
}
Text(viewModel.viewState.footerMessage)
.font(theme.fonts.footnote)
.foregroundColor(textFieldFooterColor)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("textFieldFooter")
}
Button { viewModel.send(viewAction: .confirm) } label: {
Button(action: submit) {
Text(viewModel.viewState.buttonTitle)
}
.buttonStyle(PrimaryActionButtonStyle())
@ -107,6 +101,20 @@ struct AuthenticationServerSelectionScreen: View {
}
}
/// The text field, extracted for iOS 15 modifiers to be applied.
var textField: some View {
TextField(VectorL10n.authenticationServerSelectionServerUrl, text: $viewModel.homeserverAddress) {
isEditingTextField = $0
}
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.textFieldStyle(BorderedInputFieldStyle(isEditing: isEditingTextField,
isError: viewModel.viewState.isShowingFooterError))
.onChange(of: viewModel.homeserverAddress) { _ in viewModel.send(viewAction: .clearFooterError) }
.accessibilityIdentifier("addressTextField")
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
@ -120,13 +128,10 @@ struct AuthenticationServerSelectionScreen: View {
}
}
/// Ensures the textfield is on screen when editing starts.
///
/// This is required due to the `.ignoresSafeArea(.keyboard)` modifier which preserves
/// the spacing between the Next button and the EMS banner when the keyboard appears.
func textFieldEditingChangeHandler(isEditing: Bool) {
guard isEditing else { return }
withAnimation { scrollView?.scrollTo(Constants.textFieldID) }
/// Sends the `confirm` view action so long as the text field input is valid.
func submit() {
guard !viewModel.viewState.hasValidationError else { return }
viewModel.send(viewAction: .confirm)
}
}

View file

@ -25,7 +25,7 @@ struct AuthenticationTermsPolicy: Identifiable, Equatable {
/// The policy's title.
let title: String
/// The policy's description.
let description: String
let subtitle: String
/// Whether or not the policy has been accepted.
var accepted: Bool = false
}
@ -72,6 +72,8 @@ enum AuthenticationTermsViewAction {
enum AuthenticationTermsErrorType: Hashable {
/// An error response from the homeserver.
case mxError(String)
/// The homeserver supplied an invalid URL for the policy.
case invalidPolicyURL
/// An unknown error occurred.
case unknown
}

View file

@ -29,7 +29,7 @@ class AuthenticationTermsViewModel: AuthenticationTermsViewModelType, Authentica
// MARK: Public
@MainActor var callback: ((AuthenticationTermsViewModelResult) -> Void)?
var callback: (@MainActor (AuthenticationTermsViewModelResult) -> Void)?
// MARK: - Setup
@ -56,6 +56,10 @@ class AuthenticationTermsViewModel: AuthenticationTermsViewModelType, Authentica
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
message: message)
case .invalidPolicyURL:
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
message: VectorL10n.authenticationTermsPolicyUrlError)
case .unknown:
state.bindings.alertInfo = AlertInfo(id: type)
}

View file

@ -18,7 +18,7 @@ import Foundation
protocol AuthenticationTermsViewModelProtocol {
@MainActor var callback: ((AuthenticationTermsViewModelResult) -> Void)? { get set }
var callback: (@MainActor (AuthenticationTermsViewModelResult) -> Void)? { get set }
var context: AuthenticationTermsViewModelType.Context { get }
/// Display an error to the user.

View file

@ -16,22 +16,16 @@
import SwiftUI
import CommonKit
import SafariServices
struct AuthenticationTermsCoordinatorParameters {
let registrationWizard: RegistrationWizard
/// The policies to be accepted by the user.
let policies: [String: String]
let localizedPolicies: [MXLoginPolicyData]
/// The address of the homeserver (shown beneath the policies).
let homeserverAddress: String
}
enum AuthenticationTermsCoordinatorResult {
/// The screen completed with the associated registration result.
case completed(RegistrationResult)
/// The user would like to cancel the flow.
case cancel
}
final class AuthenticationTermsCoordinator: Coordinator, Presentable {
// MARK: - Properties
@ -39,7 +33,7 @@ final class AuthenticationTermsCoordinator: Coordinator, Presentable {
// MARK: Private
private let parameters: AuthenticationTermsCoordinatorParameters
private let authenticationTermsHostingController: UIViewController
private let authenticationTermsHostingController: VectorHostingController
private var authenticationTermsViewModel: AuthenticationTermsViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
@ -58,18 +52,22 @@ final class AuthenticationTermsCoordinator: Coordinator, Presentable {
// Must be used only internally
var childCoordinators: [Coordinator] = []
@MainActor var callback: ((AuthenticationTermsCoordinatorResult) -> Void)?
var callback: (@MainActor (AuthenticationRegistrationStageResult) -> Void)?
// MARK: - Setup
@MainActor init(parameters: AuthenticationTermsCoordinatorParameters) {
self.parameters = parameters
let policies = parameters.policies.map { AuthenticationTermsPolicy(url: $0.value, title: $0.key, description: parameters.homeserverAddress) }
let subtitle = HomeserverAddress.displayable(parameters.homeserverAddress)
let policies = parameters.localizedPolicies.compactMap { AuthenticationTermsPolicy(url: $0.url, title: $0.name, subtitle: subtitle) }
let viewModel = AuthenticationTermsViewModel(policies: policies)
let view = AuthenticationTermsScreen(viewModel: viewModel.context)
authenticationTermsViewModel = viewModel
authenticationTermsHostingController = VectorHostingController(rootView: view)
authenticationTermsHostingController.vc_removeBackTitle()
authenticationTermsHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationTermsHostingController)
}
@ -136,9 +134,17 @@ final class AuthenticationTermsCoordinator: Coordinator, Presentable {
}
}
/// Present the policy in a modal.
/// Present the policy page in a modal.
@MainActor private func show(_ policy: AuthenticationTermsPolicy) {
// TODO
guard let url = URL(string: policy.url) else {
authenticationTermsViewModel.displayError(.invalidPolicyURL)
return
}
let safariViewController = SFSafariViewController(url: url)
safariViewController.modalPresentationStyle = .pageSheet
toPresentable().present(safariViewController, animated: true)
}
/// Processes an error to either update the flow or display it to the user.

View file

@ -39,17 +39,17 @@ enum MockAuthenticationTermsScreenState: MockScreenState, CaseIterable {
case .matrixDotOrg:
viewModel = AuthenticationTermsViewModel(policies: [AuthenticationTermsPolicy(url: "https://matrix-client.matrix.org/_matrix/consent?v=1.0",
title: "Terms and Conditions",
description: "matrix.org")])
subtitle: "matrix.org")])
case .accepted:
viewModel = AuthenticationTermsViewModel(policies: [AuthenticationTermsPolicy(url: "https://matrix-client.matrix.org/_matrix/consent?v=1.0",
title: "Terms and Conditions",
description: "matrix.org",
subtitle: "matrix.org",
accepted: true)])
case .multiple:
viewModel = AuthenticationTermsViewModel(policies: [
AuthenticationTermsPolicy(url: "https://example.com/terms", title: "Terms and Conditions", description: "example.com"),
AuthenticationTermsPolicy(url: "https://example.com/privacy", title: "Privacy Policy", description: "example.com"),
AuthenticationTermsPolicy(url: "https://example.com/conduct", title: "Code of Conduct", description: "example.com")
AuthenticationTermsPolicy(url: "https://example.com/terms", title: "Terms and Conditions", subtitle: "example.com"),
AuthenticationTermsPolicy(url: "https://example.com/privacy", title: "Privacy Policy", subtitle: "example.com"),
AuthenticationTermsPolicy(url: "https://example.com/conduct", title: "Code of Conduct", subtitle: "example.com")
])
}

View file

@ -53,7 +53,7 @@ struct AuthenticationTermsListItem: View {
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
Text(policy.description)
Text(policy.subtitle)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.tertiaryContent)
}
@ -77,10 +77,10 @@ struct AuthenticationTermsListItem: View {
struct Previews_AuthenticationTermsListItem_Previews: PreviewProvider {
static var unaccepted = AuthenticationTermsPolicy(url: "",
title: "Terms and Conditions",
description: "matrix.org")
subtitle: "matrix.org")
static var accepted = AuthenticationTermsPolicy(url: "",
title: "Terms and Conditions",
description: "matrix.org",
subtitle: "matrix.org",
accepted: true)
static var previews: some View {
VStack(spacing: 0) {

View file

@ -46,6 +46,7 @@ struct AuthenticationTermsScreen: View {
.padding(.bottom, 16)
}
.background(theme.colors.background.ignoresSafeArea())
.toolbar { toolbar }
.alert(item: $viewModel.alertInfo) { $0.alert }
.accentColor(theme.colors.accent)
}
@ -91,6 +92,15 @@ struct AuthenticationTermsScreen: View {
.accessibilityIdentifier("nextButton")
}
}
/// A simple toolbar with a cancel button.
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(VectorL10n.cancel) {
viewModel.send(viewAction: .cancel)
}
}
}
}
// MARK: - Previews

View file

@ -27,7 +27,7 @@ class AuthenticationVerifyEmailViewModel: AuthenticationVerifyEmailViewModelType
// MARK: Public
@MainActor var callback: ((AuthenticationVerifyEmailViewModelResult) -> Void)?
var callback: (@MainActor (AuthenticationVerifyEmailViewModelResult) -> Void)?
// MARK: - Setup

View file

@ -18,7 +18,7 @@ import Foundation
protocol AuthenticationVerifyEmailViewModelProtocol {
@MainActor var callback: ((AuthenticationVerifyEmailViewModelResult) -> Void)? { get set }
var callback: (@MainActor (AuthenticationVerifyEmailViewModelResult) -> Void)? { get set }
var context: AuthenticationVerifyEmailViewModelType.Context { get }
/// Updates the view to reflect that a verification email was successfully sent.

View file

@ -18,7 +18,6 @@ import SwiftUI
import CommonKit
struct AuthenticationVerifyEmailCoordinatorParameters {
let authenticationService: AuthenticationService
let registrationWizard: RegistrationWizard
}
@ -36,8 +35,6 @@ final class AuthenticationVerifyEmailCoordinator: Coordinator, Presentable {
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
/// The authentication service used for the registration.
private var authenticationService: AuthenticationService { parameters.authenticationService }
/// The wizard used to handle the registration flow.
private var registrationWizard: RegistrationWizard { parameters.registrationWizard }
@ -51,7 +48,7 @@ final class AuthenticationVerifyEmailCoordinator: Coordinator, Presentable {
// Must be used only internally
var childCoordinators: [Coordinator] = []
@MainActor var callback: ((AuthenticationVerifyEmailViewModelResult) -> Void)?
var callback: (@MainActor (AuthenticationRegistrationStageResult) -> Void)?
// MARK: - Setup
@ -91,9 +88,9 @@ final class AuthenticationVerifyEmailCoordinator: Coordinator, Presentable {
case .send(let emailAddress):
self.sendEmail(emailAddress)
case .resend:
self.resentEmail()
self.resendEmail()
case .cancel:
#warning("Reset the flow.")
self.callback?(.cancel)
}
}
}
@ -108,6 +105,7 @@ final class AuthenticationVerifyEmailCoordinator: Coordinator, Presentable {
loadingIndicator = nil
}
/// Sends a validation email to the supplied address and then begins polling the server.
@MainActor private func sendEmail(_ address: String) {
let threePID = RegisterThreePID.email(address)
@ -115,13 +113,22 @@ final class AuthenticationVerifyEmailCoordinator: Coordinator, Presentable {
currentTask = Task { [weak self] in
do {
_ = try await registrationWizard.addThreePID(threePID: threePID)
let result = try await registrationWizard.addThreePID(threePID: threePID)
// Shouldn't be reachable but just in case, continue the flow.
guard !Task.isCancelled else { return }
authenticationVerifyEmailViewModel.updateForSentEmail()
pollForEmailValidation()
self?.callback?(.completed(result))
self?.stopLoading()
} catch RegistrationError.waitingForThreePIDValidation {
// If everything went well, begin polling the server.
authenticationVerifyEmailViewModel.updateForSentEmail()
self?.stopLoading()
checkForEmailValidation()
} catch is CancellationError {
return
} catch {
self?.stopLoading()
self?.handleError(error)
@ -129,17 +136,26 @@ final class AuthenticationVerifyEmailCoordinator: Coordinator, Presentable {
}
}
@MainActor private func resentEmail() {
/// Resends an email to the previously entered address and then resumes polling the server.
@MainActor private func resendEmail() {
startLoading()
currentTask = Task { [weak self] in
do {
_ = try await registrationWizard.sendAgainThreePID()
let result = try await registrationWizard.sendAgainThreePID()
// Shouldn't be reachable but just in case, continue the flow.
guard !Task.isCancelled else { return }
pollForEmailValidation()
self?.callback?(.completed(result))
self?.stopLoading()
} catch RegistrationError.waitingForThreePIDValidation {
// Resume polling the server.
self?.stopLoading()
checkForEmailValidation()
} catch is CancellationError {
return
} catch {
self?.stopLoading()
self?.handleError(error)
@ -147,8 +163,26 @@ final class AuthenticationVerifyEmailCoordinator: Coordinator, Presentable {
}
}
@MainActor private func pollForEmailValidation() {
// TODO
@MainActor private func checkForEmailValidation() {
currentTask = Task { [weak self] in
do {
MXLog.debug("[AuthenticationVerifyEmailCoordinator] pollForEmailValidation: Sleeping for 3 seconds.")
try await Task.sleep(nanoseconds: 3_000_000_000)
let result = try await registrationWizard.checkIfEmailHasBeenValidated()
guard !Task.isCancelled else { return }
self?.callback?(.completed(result))
} catch RegistrationError.waitingForThreePIDValidation {
// Check again, creating a poll on the server.
checkForEmailValidation()
} catch is CancellationError {
return
} catch {
self?.handleError(error)
}
}
}
/// Processes an error to either update the flow or display it to the user.

View file

@ -66,16 +66,14 @@ struct AuthenticationVerifyEmailForm: View {
/// The text field and submit button where the user enters an email address.
var mainContent: some View {
VStack(alignment: .leading, spacing: 12) {
TextField(VectorL10n.authenticationVerifyEmailTextFieldPlaceholder, text: $viewModel.emailAddress) {
isEditingTextField = $0
if #available(iOS 15.0, *) {
textField
.onSubmit(submit)
} else {
textField
}
.textFieldStyle(BorderedInputFieldStyle(isEditing: isEditingTextField, isError: false))
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
.accessibilityIdentifier("addressTextField")
Button { viewModel.send(viewAction: .send) } label: {
Button(action: submit) {
Text(VectorL10n.next)
}
.buttonStyle(PrimaryActionButtonStyle())
@ -83,4 +81,22 @@ struct AuthenticationVerifyEmailForm: View {
.accessibilityIdentifier("nextButton")
}
}
/// The text field, extracted for iOS 15 modifiers to be applied.
var textField: some View {
TextField(VectorL10n.authenticationVerifyEmailTextFieldPlaceholder, text: $viewModel.emailAddress) {
isEditingTextField = $0
}
.textFieldStyle(BorderedInputFieldStyle(isEditing: isEditingTextField, isError: false))
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
.accessibilityIdentifier("addressTextField")
}
/// Sends the `send` view action so long as a valid email address has been input.
func submit() {
guard !viewModel.viewState.hasInvalidAddress else { return }
viewModel.send(viewAction: .send)
}
}

View file

@ -46,22 +46,30 @@ struct AuthenticationVerifyEmailScreen: View {
}
}
.background(background.ignoresSafeArea())
.alert(item: $viewModel.alertInfo) { $0.alert }
.toolbar { toolbar }
.alert(item: $viewModel.alertInfo) { $0.alert }
.accentColor(theme.colors.accent)
}
@ViewBuilder
var mainContent: some View {
if viewModel.viewState.hasSentEmail {
waitingHeader
.padding(.top, OnboardingMetrics.breakerScreenTopPadding)
.padding(.bottom, 36)
waitingContent
} else {
AuthenticationVerifyEmailForm(viewModel: viewModel)
}
}
var waitingContent: some View {
VStack(spacing: 36) {
waitingHeader
.padding(.top, OnboardingMetrics.breakerScreenTopPadding)
ProgressView()
.scaleEffect(1.3)
.progressViewStyle(CircularProgressViewStyle(tint: theme.colors.secondaryContent))
}
}
/// The instructions shown whilst waiting for the user to tap the link in the email.
var waitingHeader: some View {
VStack(spacing: 8) {
@ -125,13 +133,12 @@ struct AuthenticationVerifyEmailScreen: View {
endPoint: .bottom))
}
/// A simple toolbar with a cancel button.
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button { viewModel.send(viewAction: .cancel) } label: {
Image(systemName: "chevron.backward")
Button(VectorL10n.cancel) {
viewModel.send(viewAction: .cancel)
}
.accessibilityLabel(VectorL10n.close)
.accessibilityIdentifier("cancelButton")
}
}
}

View file

@ -25,21 +25,19 @@ import Introspect
/// https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=2039%3A26415
struct BorderedInputFieldStyle: TextFieldStyle {
@Environment(\.theme) var theme: ThemeSwiftUI
@Environment(\.isEnabled) var isEnabled: Bool
@Environment(\.theme) private var theme: ThemeSwiftUI
@Environment(\.isEnabled) private var isEnabled: Bool
var isEditing: Bool = false
var isError: Bool = false
private var borderColor: Color {
if !isEnabled {
return theme.colors.quinaryContent
} else if isError {
if isError {
return theme.colors.alert
} else if isEditing {
return theme.colors.accent
}
return theme.colors.quarterlyContent
return theme.colors.quinaryContent
}
private var accentColor: Color {

View file

@ -87,8 +87,7 @@ struct OnboardingDisplayNameScreen: View {
isEditingTextField = $0
}
.autocapitalization(.words)
.textFieldStyle(BorderedInputFieldStyle(theme: _theme,
isEditing: isEditingTextField,
.textFieldStyle(BorderedInputFieldStyle(isEditing: isEditingTextField,
isError: viewModel.viewState.validationErrorMessage != nil))
Text(viewModel.viewState.textFieldFooterMessage)

View file

@ -15,6 +15,7 @@
//
import SwiftUI
import CommonKit
final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
@ -25,6 +26,9 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
private let onboardingUseCaseHostingController: VectorHostingController
private var onboardingUseCaseViewModel: OnboardingUseCaseViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
@ -41,14 +45,19 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
onboardingUseCaseHostingController = VectorHostingController(rootView: view)
onboardingUseCaseHostingController.vc_removeBackTitle()
onboardingUseCaseHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingUseCaseHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[OnboardingUseCaseSelectionCoordinator] did start.")
onboardingUseCaseViewModel.completion = { [weak self] result in
MXLog.debug("[OnboardingUseCaseSelectionCoordinator] OnboardingUseCaseViewModel did complete with result: \(result).")
guard let self = self else { return }
MXLog.debug("[OnboardingUseCaseSelectionCoordinator] OnboardingUseCaseViewModel did complete with result: \(result).")
// Show a loading indicator which can be dismissed externally by calling `stop`.
self.startLoading()
self.completion?(result)
}
}
@ -56,4 +65,21 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
func toPresentable() -> UIViewController {
return self.onboardingUseCaseHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}

View file

@ -38,7 +38,7 @@ struct PollEditFormAnswerOptionView: View {
TextField(VectorL10n.pollEditFormInputPlaceholder, text: $text, onEditingChanged: { edit in
self.focused = edit
})
.textFieldStyle(BorderedInputFieldStyle(theme: _theme, isEditing: focused))
.textFieldStyle(BorderedInputFieldStyle(isEditing: focused))
Button(action: onDelete) {
Image(uiImage:Asset.Images.pollDeleteOptionIcon.image)
}