357 lines
16 KiB
Swift
357 lines
16 KiB
Swift
//
|
|
// AuthenticationViewController.swift
|
|
// Mastodon
|
|
//
|
|
// Created by MainasuK Cirno on 2021/1/29.
|
|
//
|
|
|
|
import os.log
|
|
import UIKit
|
|
import Combine
|
|
import MastodonSDK
|
|
import UITextField_Shake
|
|
|
|
final class AuthenticationViewController: UIViewController, NeedsDependency {
|
|
|
|
var disposeBag = Set<AnyCancellable>()
|
|
|
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
|
|
|
var viewModel: AuthenticationViewModel!
|
|
|
|
let domainLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.font = .preferredFont(forTextStyle: .headline)
|
|
label.textColor = Asset.Colors.Label.primary.color
|
|
label.text = "Domain:"
|
|
return label
|
|
}()
|
|
|
|
let domainTextField: UITextField = {
|
|
let textField = UITextField()
|
|
textField.placeholder = "example.com"
|
|
textField.autocapitalizationType = .none
|
|
textField.autocorrectionType = .no
|
|
textField.keyboardType = .URL
|
|
return textField
|
|
}()
|
|
|
|
let signInButton: UIButton = {
|
|
let button = UIButton(type: .system)
|
|
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
|
button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Background.secondarySystemBackground.color), for: .normal)
|
|
button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Background.secondarySystemBackground.color.withAlphaComponent(0.8)), for: .disabled)
|
|
button.setTitleColor(Asset.Colors.Label.primary.color, for: .normal)
|
|
button.setTitle("Sign in", for: .normal)
|
|
button.layer.masksToBounds = true
|
|
button.layer.cornerRadius = 8
|
|
button.layer.cornerCurve = .continuous
|
|
return button
|
|
}()
|
|
|
|
let signUpButton: UIButton = {
|
|
let button = UIButton(type: .system)
|
|
button.titleLabel?.font = .preferredFont(forTextStyle: .subheadline)
|
|
button.setTitleColor(Asset.Colors.Button.highlight.color, for: .normal)
|
|
button.setTitleColor(.systemGray, for: .disabled)
|
|
button.setTitle("Sign up", for: .normal)
|
|
return button
|
|
}()
|
|
|
|
let signInActivityIndicatorView: UIActivityIndicatorView = {
|
|
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
|
activityIndicatorView.hidesWhenStopped = true
|
|
return activityIndicatorView
|
|
}()
|
|
|
|
let signUpActivityIndicatorView: UIActivityIndicatorView = {
|
|
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
|
activityIndicatorView.hidesWhenStopped = true
|
|
return activityIndicatorView
|
|
}()
|
|
}
|
|
|
|
extension AuthenticationViewController {
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
overrideUserInterfaceStyle = .dark // FIXME:
|
|
title = "Authentication"
|
|
view.backgroundColor = Asset.Colors.Background.systemBackground.color
|
|
|
|
domainLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(domainLabel)
|
|
NSLayoutConstraint.activate([
|
|
domainLabel.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 16),
|
|
domainLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
|
|
domainLabel.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
|
])
|
|
|
|
domainTextField.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(domainTextField)
|
|
NSLayoutConstraint.activate([
|
|
domainTextField.topAnchor.constraint(equalTo: domainLabel.bottomAnchor, constant: 8),
|
|
domainTextField.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
|
|
domainTextField.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
|
])
|
|
|
|
signInButton.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(signInButton)
|
|
NSLayoutConstraint.activate([
|
|
signInButton.topAnchor.constraint(equalTo: domainTextField.bottomAnchor, constant: 20),
|
|
signInButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
|
|
signInButton.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
|
signInButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
|
|
])
|
|
|
|
signInActivityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(signInActivityIndicatorView)
|
|
NSLayoutConstraint.activate([
|
|
signInActivityIndicatorView.centerXAnchor.constraint(equalTo: signInButton.centerXAnchor),
|
|
signInActivityIndicatorView.centerYAnchor.constraint(equalTo: signInButton.centerYAnchor),
|
|
])
|
|
|
|
signUpButton.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(signUpButton)
|
|
NSLayoutConstraint.activate([
|
|
signUpButton.topAnchor.constraint(equalTo: signInButton.bottomAnchor, constant: 8),
|
|
signUpButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
|
|
signUpButton.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
|
signUpButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
|
|
])
|
|
|
|
signUpActivityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(signUpActivityIndicatorView)
|
|
NSLayoutConstraint.activate([
|
|
signUpActivityIndicatorView.centerXAnchor.constraint(equalTo: signUpButton.centerXAnchor),
|
|
signUpActivityIndicatorView.centerYAnchor.constraint(equalTo: signUpButton.centerYAnchor),
|
|
])
|
|
|
|
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: domainTextField)
|
|
.compactMap { notification in
|
|
guard let textField = notification.object as? UITextField? else { return nil }
|
|
return textField?.text ?? ""
|
|
}
|
|
.assign(to: \.value, on: viewModel.input)
|
|
.store(in: &disposeBag)
|
|
|
|
viewModel.isAuthenticating
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] isAuthenticating in
|
|
guard let self = self else { return }
|
|
isAuthenticating ? self.signInActivityIndicatorView.startAnimating() : self.signInActivityIndicatorView.stopAnimating()
|
|
self.signInButton.setTitle(isAuthenticating ? "" : "Sign in", for: .normal)
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
viewModel.isRegistering
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] isRegistering in
|
|
guard let self = self else { return }
|
|
isRegistering ? self.signUpActivityIndicatorView.startAnimating() : self.signUpActivityIndicatorView.stopAnimating()
|
|
self.signUpButton.setTitle(isRegistering ? "" : "Sign up", for: .normal)
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
viewModel.isIdle
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] isIdle in
|
|
guard let self = self else { return }
|
|
self.signInButton.isEnabled = isIdle
|
|
self.signUpButton.isEnabled = isIdle
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
|
|
viewModel.authenticated
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] domain, user in
|
|
guard let self = self else { return }
|
|
// reset view hierarchy only if needs
|
|
if self.viewModel.viewHierarchyShouldReset {
|
|
self.context.authenticationService.activeMastodonUser(domain: domain, userID: user.id)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] result in
|
|
guard let self = self else { return }
|
|
switch result {
|
|
case .failure(let error):
|
|
assertionFailure(error.localizedDescription)
|
|
case .success(let isActived):
|
|
assert(isActived)
|
|
self.coordinator.setup()
|
|
}
|
|
}
|
|
.store(in: &self.disposeBag)
|
|
} else {
|
|
self.dismiss(animated: true, completion: nil)
|
|
}
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
viewModel.error
|
|
.compactMap { $0 }
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] error in
|
|
guard let self = self else { return }
|
|
let alertController = UIAlertController(error, preferredStyle: .alert)
|
|
let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
|
|
alertController.addAction(okAction)
|
|
self.coordinator.present(
|
|
scene: .alertController(alertController: alertController),
|
|
from: nil,
|
|
transition: .alertController(animated: true, completion: nil)
|
|
)
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
signInButton.addTarget(self, action: #selector(AuthenticationViewController.signInButtonPressed(_:)), for: .touchUpInside)
|
|
signUpButton.addTarget(self, action: #selector(AuthenticationViewController.signUpButtonPressed(_:)), for: .touchUpInside)
|
|
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
domainTextField.becomeFirstResponder()
|
|
}
|
|
|
|
}
|
|
|
|
extension AuthenticationViewController {
|
|
|
|
@objc private func signInButtonPressed(_ sender: UIButton) {
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
|
guard viewModel.isDomainValid.value, let domain = viewModel.domain.value else {
|
|
domainTextField.shake()
|
|
return
|
|
}
|
|
guard viewModel.isIdle.value else { return }
|
|
viewModel.isAuthenticating.value = true
|
|
context.apiService.createApplication(domain: domain)
|
|
.tryMap { response -> AuthenticationViewModel.AuthenticateInfo in
|
|
let application = response.value
|
|
guard let info = AuthenticationViewModel.AuthenticateInfo(domain: domain, application: application) else {
|
|
throw APIService.APIError.explicit(.badResponse)
|
|
}
|
|
return info
|
|
}
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] completion in
|
|
guard let self = self else { return }
|
|
// trigger state update
|
|
self.viewModel.isAuthenticating.value = false
|
|
|
|
switch completion {
|
|
case .failure(let error):
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
|
self.viewModel.error.value = error
|
|
case .finished:
|
|
break
|
|
}
|
|
} receiveValue: { [weak self] info in
|
|
guard let self = self else { return }
|
|
let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.authorizeURL)
|
|
self.viewModel.authenticate(
|
|
info: info,
|
|
pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher
|
|
)
|
|
self.viewModel.mastodonPinBasedAuthenticationViewController = self.coordinator.present(
|
|
scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel),
|
|
from: nil,
|
|
transition: .modal(animated: true, completion: nil)
|
|
)
|
|
}
|
|
.store(in: &disposeBag)
|
|
}
|
|
|
|
private struct SignUpResponseFirst {
|
|
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
|
|
let application: Mastodon.Response.Content<Mastodon.Entity.Application>
|
|
}
|
|
|
|
private struct SignUpResponseSecond {
|
|
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
|
|
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
|
|
}
|
|
|
|
private struct SignUpResponseThird {
|
|
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
|
|
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
|
|
let applicationToken: Mastodon.Response.Content<Mastodon.Entity.Token>
|
|
}
|
|
|
|
@objc private func signUpButtonPressed(_ sender: UIButton) {
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
|
guard viewModel.isDomainValid.value, let domain = viewModel.domain.value else {
|
|
domainTextField.shake()
|
|
return
|
|
}
|
|
guard viewModel.isIdle.value else { return }
|
|
viewModel.isRegistering.value = true
|
|
|
|
context.apiService.instance(domain: domain)
|
|
.compactMap { [weak self] response -> AnyPublisher<SignUpResponseFirst, Error>? in
|
|
guard let self = self else { return nil }
|
|
guard response.value.registrations != false else {
|
|
return Fail(error: AuthenticationViewModel.AuthenticationError.registrationClosed).eraseToAnyPublisher()
|
|
}
|
|
return self.context.apiService.createApplication(domain: domain)
|
|
.map { SignUpResponseFirst(instance: response, application: $0) }
|
|
.eraseToAnyPublisher()
|
|
}
|
|
.switchToLatest()
|
|
.tryMap { response -> SignUpResponseSecond in
|
|
let application = response.application.value
|
|
guard let authenticateInfo = AuthenticationViewModel.AuthenticateInfo(domain: domain, application: application) else {
|
|
throw APIService.APIError.explicit(.badResponse)
|
|
}
|
|
return SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo)
|
|
}
|
|
.compactMap { [weak self] response -> AnyPublisher<SignUpResponseThird, Error>? in
|
|
guard let self = self else { return nil }
|
|
let instance = response.instance
|
|
let authenticateInfo = response.authenticateInfo
|
|
return self.context.apiService.applicationAccessToken(
|
|
domain: domain,
|
|
clientID: authenticateInfo.clientID,
|
|
clientSecret: authenticateInfo.clientSecret
|
|
)
|
|
.map { SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) }
|
|
.eraseToAnyPublisher()
|
|
}
|
|
.switchToLatest()
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] completion in
|
|
guard let self = self else { return }
|
|
self.viewModel.isRegistering.value = false
|
|
|
|
switch completion {
|
|
case .failure(let error):
|
|
self.viewModel.error.send(error)
|
|
case .finished:
|
|
break
|
|
}
|
|
} receiveValue: { [weak self] response in
|
|
guard let self = self else { return }
|
|
let mastodonRegisterViewModel = MastodonRegisterViewModel(
|
|
domain: domain,
|
|
authenticateInfo: response.authenticateInfo,
|
|
instance: response.instance.value,
|
|
applicationToken: response.applicationToken.value
|
|
)
|
|
self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: self, transition: .show)
|
|
}
|
|
.store(in: &disposeBag)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - UIAdaptivePresentationControllerDelegate
|
|
extension AuthenticationViewController: UIAdaptivePresentationControllerDelegate {
|
|
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
|
|
return .fullScreen
|
|
}
|
|
}
|