Add ReCaptcha screen (#6135)

Support dark mode in MXKAuthenticationRecaptchaWebView.
Begin implementing the ReCaptcha coordinator.
This commit is contained in:
Doug
2022-05-11 09:57:37 +01:00
committed by GitHub
parent 46ee4e416a
commit f9fbc6f599
20 changed files with 626 additions and 7 deletions

View File

@@ -52,6 +52,8 @@
"authentication_terms_title" = "Privacy policy";
"authentication_terms_message" = "Please read through T&C. You must accept in order to continue.";
"authentication_recaptcha_message" = "This server would like to make sure you are not a robot";
// MARK: Spaces WIP
"spaces_feature_not_available" = "This feature isn't available here. For now, you can do this with %@ on your computer.";

View File

@@ -10,6 +10,10 @@ import Foundation
// swiftlint:disable function_parameter_count identifier_name line_length type_body_length
public extension VectorL10n {
/// This server would like to make sure you are not a robot
static var authenticationRecaptchaMessage: String {
return VectorL10n.tr("Untranslated", "authentication_recaptcha_message")
}
/// Join millions for free on the largest public server
static var authenticationRegistrationMatrixDescription: String {
return VectorL10n.tr("Untranslated", "authentication_registration_matrix_description")

View File

@@ -131,6 +131,8 @@
[self.ssoButton setTitle:[VectorL10n authLoginSingleSignOn] forState:UIControlStateNormal];
[self.ssoButton setTitle:[VectorL10n authLoginSingleSignOn] forState:UIControlStateHighlighted];
self.ssoButton.backgroundColor = ThemeService.shared.theme.tintColor;
self.recaptchaContainer.backgroundColor = ThemeService.shared.theme.backgroundColor;
if (self.userLoginTextField.placeholder)
{

View File

@@ -16,10 +16,12 @@
*/
#import "MXKAuthenticationRecaptchaWebView.h"
#import "ThemeService.h"
NSString *kMXKRecaptchaHTMLString = @"<html> \
<head> \
<meta name='viewport' content='initial-scale=1.0' /> \
<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*/ \
@@ -33,7 +35,8 @@ var verifyCallback = function(response) { \
var onloadCallback = function() { \
grecaptcha.render('recaptcha_widget', { \
'sitekey' : '%@', \
'callback': verifyCallback \
'callback': verifyCallback, \
'theme': '%@' \
}); \
}; \
</script> \
@@ -78,7 +81,9 @@ var onloadCallback = function() { \
[self addSubview:activityIndicator];
[activityIndicator startAnimating];
NSString *htmlString = [NSString stringWithFormat:kMXKRecaptchaHTMLString, siteKey];
NSString *theme = ThemeService.shared.isCurrentThemeDark ? @"dark" : @"light";
NSString *htmlString = [NSString stringWithFormat:kMXKRecaptchaHTMLString, siteKey, theme];
[self loadHTMLString:htmlString baseURL:[NSURL URLWithString:homeServer]];
}

View File

@@ -62,6 +62,8 @@ targets:
- path: ../Riot/Managers/Widgets/WidgetConstants.m
- path: ../Riot/PropertyWrappers/UserDefaultsBackedPropertyWrapper.swift
- path: ../Riot/Modules/MatrixKit
excludes:
- "**/MXKAuthenticationRecaptchaWebView.*"
- path: ../Riot/Modules/Analytics
- path: ../Riot/Managers/UserSessions
- path: ../Riot/Managers/AppInfo/

View File

@@ -70,6 +70,8 @@ targets:
- path: ../Riot/Assets/SharedImages.xcassets
buildPhase: resources
- path: ../Riot/Modules/MatrixKit
excludes:
- "**/MXKAuthenticationRecaptchaWebView.*"
- path: ../Riot/Modules/Analytics
- path: ../Riot/Managers/UserSessions
excludes:

View File

@@ -0,0 +1,49 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
// MARK: View model
enum AuthenticationReCaptchaViewModelResult {
/// Perform the ReCaptcha stage with the associated response.
case validate(String)
/// Cancel the flow.
case cancel
}
// MARK: View
struct AuthenticationReCaptchaViewState: BindableState {
/// The `sitekey` passed to the ReCaptcha widget.
let siteKey: String
/// The homeserver URL used for the web view.
let homeserverURL: URL
/// View state that can be bound to from SwiftUI.
var bindings = AuthenticationReCaptchaBindings()
}
struct AuthenticationReCaptchaBindings {
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<Int>?
}
enum AuthenticationReCaptchaViewAction {
/// Perform the ReCaptcha stage with the associated response.
case validate(String)
/// Cancel the flow.
case cancel
}

View File

@@ -0,0 +1,50 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
typealias AuthenticationReCaptchaViewModelType = StateStoreViewModel<AuthenticationReCaptchaViewState,
Never,
AuthenticationReCaptchaViewAction>
class AuthenticationReCaptchaViewModel: AuthenticationReCaptchaViewModelType, AuthenticationReCaptchaViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
@MainActor var callback: ((AuthenticationReCaptchaViewModelResult) -> Void)?
// MARK: - Setup
init(siteKey: String, homeserverURL: URL) {
super.init(initialViewState: AuthenticationReCaptchaViewState(siteKey: siteKey,
homeserverURL: homeserverURL))
}
// MARK: - Public
override func process(viewAction: AuthenticationReCaptchaViewAction) {
switch viewAction {
case .cancel:
Task { await callback?(.cancel) }
case .validate(let response):
Task { await callback?(.validate(response)) }
}
}
}

View File

@@ -0,0 +1,23 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol AuthenticationReCaptchaViewModelProtocol {
@MainActor var callback: ((AuthenticationReCaptchaViewModelResult) -> Void)? { get set }
var context: AuthenticationReCaptchaViewModelType.Context { get }
}

View File

@@ -0,0 +1,137 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
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
}
final class AuthenticationReCaptchaCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: AuthenticationReCaptchaCoordinatorParameters
private let authenticationReCaptchaHostingController: UIViewController
private var authenticationReCaptchaViewModel: AuthenticationReCaptchaViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
/// The wizard used to handle the registration flow.
private var registrationWizard: RegistrationWizard { parameters.registrationWizard }
private var currentTask: Task<Void, Error>? {
willSet {
currentTask?.cancel()
}
}
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
@MainActor var callback: ((AuthenticationReCaptchaCoordinatorResult) -> 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 view = AuthenticationReCaptchaScreen(viewModel: viewModel.context)
authenticationReCaptchaViewModel = viewModel
authenticationReCaptchaHostingController = VectorHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationReCaptchaHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[AuthenticationReCaptchaCoordinator] did start.")
Task { await setupViewModel() }
}
func toPresentable() -> UIViewController {
return self.authenticationReCaptchaHostingController
}
// MARK: - Private
/// Set up the view model. This method is extracted from `start()` so it can run on the `MainActor`.
@MainActor private func setupViewModel() {
authenticationReCaptchaViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationReCaptchaCoordinator] AuthenticationReCaptchaViewModel did complete with result: \(result).")
switch result {
case .validate(let response):
self.performReCaptcha(response)
case .cancel:
#warning("Reset the flow")
}
}
}
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
@MainActor private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) {
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
@MainActor private func stopLoading() {
loadingIndicator = nil
}
/// Performs the ReCaptcha stage with the supplied response string.
@MainActor private func performReCaptcha(_ response: String) {
startLoading()
currentTask = Task { [weak self] in
do {
let result = try await registrationWizard.performReCaptcha(response: response)
guard !Task.isCancelled else { return }
callback?(.completed(result))
self?.stopLoading()
} catch {
self?.stopLoading()
}
}
}
}

View File

@@ -0,0 +1,47 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockAuthenticationReCaptchaScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case standard
/// The associated screen
var screenType: Any.Type {
AuthenticationReCaptchaScreen.self
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: AuthenticationReCaptchaViewModel
switch self {
case .standard:
viewModel = AuthenticationReCaptchaViewModel(siteKey: "12345", homeserverURL: URL(string: "https://matrix-client.matrix.org")!)
}
// can simulate service and viewModel actions here if needs be.
return (
[viewModel], AnyView(AuthenticationReCaptchaScreen(viewModel: viewModel.context))
)
}
}

View File

@@ -0,0 +1,22 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import RiotSwiftUI
class AuthenticationReCaptchaUITests: MockScreenTest {
// Nothing to test as the view only has a single state.
}

View File

@@ -0,0 +1,23 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import RiotSwiftUI
class AuthenticationReCaptchaViewModelTests: XCTestCase {
// Nothing to test as the view model has no mutable state.
}

View File

@@ -0,0 +1,97 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct AuthenticationReCaptchaScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
@State private var isLoading = false
// MARK: Public
@ObservedObject var viewModel: AuthenticationReCaptchaViewModel.Context
// MARK: Views
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 40) {
header
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
.padding(.horizontal, 16)
.fixedSize(horizontal: false, vertical: true)
recaptcha
.frame(minHeight: 500)
}
.readableFrame()
.frame(minHeight: geometry.size.height)
.ignoresSafeArea(.all, edges: .bottom)
}
}
.navigationBarTitleDisplayMode(.inline)
.background(theme.colors.background.ignoresSafeArea())
.alert(item: $viewModel.alertInfo) { $0.alert }
.accentColor(theme.colors.accent)
}
/// The header containing the icon, title and message.
var header: some View {
VStack(spacing: 8) {
OnboardingIconImage(image: Asset.Images.onboardingCongratulationsIcon)
.padding(.bottom, 8)
Text(VectorL10n.authenticationRegistrationTitle)
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
Text(VectorL10n.authenticationRecaptchaMessage)
.font(theme.fonts.body)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
}
}
/// The web view that shows the ReCaptcha to the user.
var recaptcha: some View {
AuthenticationRecaptchaWebView(siteKey: viewModel.viewState.siteKey,
homeserverURL: viewModel.viewState.homeserverURL,
isLoading: $isLoading) { response in
viewModel.send(viewAction: .validate(response))
}
}
}
// MARK: - Previews
struct AuthenticationReCaptcha_Previews: PreviewProvider {
static let stateRenderer = MockAuthenticationReCaptchaScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
.theme(.dark).preferredColorScheme(.dark)
}
}

View File

@@ -0,0 +1,152 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import WebKit
struct AuthenticationRecaptchaWebView: UIViewRepresentable {
// MARK: - Properties
// MARK: Public
/// The `siteKey` string to pass to the ReCaptcha widget.
let siteKey: String
/// The homeserver's URL, used so ReCaptcha can validate where the request is coming from.
let homeserverURL: URL
/// A binding to boolean that controls whether or not a loading spinner should be shown.
@Binding var isLoading: Bool
/// The completion called when the ReCaptcha was successful. The response string
/// is passed into the closure as the only argument.
let completion: (String) -> Void
// MARK: Private
@Environment(\.theme) private var theme
// MARK: - Setup
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
#if DEBUG
// Use a randomised user agent to encourage the ReCaptcha to show a challenge.
webView.customUserAgent = "Show Me The Traffic Lights \(Float.random(in: 1...100))"
#endif
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
let recaptchaTheme: Coordinator.ReCaptchaTheme = theme.isDark ? .dark : .light
webView.loadHTMLString(context.coordinator.htmlString(with: siteKey, using: recaptchaTheme), baseURL: homeserverURL)
}
func makeCoordinator() -> Coordinator {
let coordinator = Coordinator(isLoading: $isLoading)
coordinator.completion = completion
return coordinator
}
// MARK: - Coordinator
class Coordinator: NSObject, WKNavigationDelegate {
/// The theme used to render the ReCaptcha
enum ReCaptchaTheme: String { case light, dark }
/// A binding to boolean that controls whether or not a loading spinner should be shown.
@Binding var isLoading: Bool
/// The completion called when the ReCaptcha was successful. The response string
/// is passed into the closure as the only argument.
var completion: ((String) -> Void)?
init(isLoading: Binding<Bool>) {
self._isLoading = isLoading
}
/// Generates the HTML page to show for the given `siteKey` and `theme`.
func htmlString(with siteKey: String, using theme: ReCaptchaTheme) -> String {
"""
<html>
<head>
<meta name='viewport' content='initial-scale=1.0' />
<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;
};
var onloadCallback = function() {
grecaptcha.render('recaptcha_widget', {
'sitekey' : '\(siteKey)',
'callback': verifyCallback,
'theme': '\(theme.rawValue)'
});
};
</script>
</head>
<body style="margin: 16px;">
<div id="recaptcha_widget"></div>
<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer>
</script>
</body>
</html>
"""
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
isLoading = true
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
isLoading = false
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
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 }
completion?(response)
return .cancel
}
}
}

View File

@@ -42,8 +42,7 @@ struct AuthenticationTermsScreen: View {
button
.padding(.horizontal)
}
.frame(maxWidth: OnboardingMetrics.maxContentWidth)
.frame(maxWidth: .infinity)
.readableFrame()
.padding(.bottom, 16)
}
.background(theme.colors.background.ignoresSafeArea())

View File

@@ -37,9 +37,9 @@ final class AuthenticationVerifyEmailCoordinator: Coordinator, Presentable {
private var loadingIndicator: UserIndicator?
/// The authentication service used for the registration.
var authenticationService: AuthenticationService { parameters.authenticationService }
private var authenticationService: AuthenticationService { parameters.authenticationService }
/// The wizard used to handle the registration flow.
var registrationWizard: RegistrationWizard { parameters.registrationWizard }
private var registrationWizard: RegistrationWizard { parameters.registrationWizard }
private var currentTask: Task<Void, Error>? {
willSet {

View File

@@ -21,6 +21,7 @@ import Foundation
enum MockAppScreens {
static let appScreens: [MockScreenState.Type] = [
MockLiveLocationSharingViewerScreenState.self,
MockAuthenticationReCaptchaScreenState.self,
MockAuthenticationTermsScreenState.self,
MockAuthenticationVerifyEmailScreenState.self,
MockAuthenticationRegistrationScreenState.self,

View File

@@ -51,6 +51,8 @@ targets:
- path: ../Riot/Managers/Locale/LocaleProviderType.swift
- path: ../Riot/Managers/Locale/LocaleProvider.swift
- path: ../Riot/Modules/MatrixKit
excludes:
- "**/MXKAuthenticationRecaptchaWebView.*"
- path: ../Riot/Modules/Analytics
- path: ../Riot/Managers/UserSessions
- path: ../Riot/Managers/AppInfo/

View File

@@ -1 +1 @@
Authentication: Create terms screen.
Authentication: Create terms and ReCaptcha screens.