mastodon-ios/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift

238 lines
9.4 KiB
Swift
Raw Normal View History

2021-02-02 08:38:54 +01:00
//
// AuthenticationViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021/2/1.
//
2021-02-02 12:31:10 +01:00
import os.log
import UIKit
2021-02-03 09:01:08 +01:00
import CoreData
import CoreDataStack
2021-02-02 08:38:54 +01:00
import Combine
2021-02-02 12:31:10 +01:00
import MastodonSDK
2021-02-02 08:38:54 +01:00
final class AuthenticationViewModel {
var disposeBag = Set<AnyCancellable>()
// input
2021-02-02 12:31:10 +01:00
let context: AppContext
let coordinator: SceneCoordinator
2021-02-03 09:01:08 +01:00
let isAuthenticationExist: Bool
2021-02-02 08:38:54 +01:00
let input = CurrentValueSubject<String, Never>("")
// output
2021-02-03 09:01:08 +01:00
let viewHierarchyShouldReset: Bool
2021-02-02 08:38:54 +01:00
let domain = CurrentValueSubject<String?, Never>(nil)
let isDomainValid = CurrentValueSubject<Bool, Never>(false)
2021-02-02 12:31:10 +01:00
let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
2021-02-05 10:53:00 +01:00
let isRegistering = CurrentValueSubject<Bool, Never>(false)
let isIdle = CurrentValueSubject<Bool, Never>(true)
2021-02-03 09:01:08 +01:00
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
2021-02-02 12:31:10 +01:00
let error = CurrentValueSubject<Error?, Never>(nil)
2021-02-02 08:38:54 +01:00
2021-02-03 09:01:08 +01:00
var mastodonPinBasedAuthenticationViewController: UIViewController?
2021-02-02 12:31:10 +01:00
2021-02-03 09:01:08 +01:00
init(context: AppContext, coordinator: SceneCoordinator, isAuthenticationExist: Bool) {
2021-02-02 12:31:10 +01:00
self.context = context
self.coordinator = coordinator
2021-02-03 09:01:08 +01:00
self.isAuthenticationExist = isAuthenticationExist
self.viewHierarchyShouldReset = isAuthenticationExist
2021-02-02 12:31:10 +01:00
2021-02-02 08:38:54 +01:00
input
.map { input in
AuthenticationViewModel.parseDomain(from: input)
2021-02-02 08:38:54 +01:00
}
.assign(to: \.value, on: domain)
.store(in: &disposeBag)
2021-02-05 10:53:00 +01:00
Publishers.CombineLatest(
isAuthenticating.eraseToAnyPublisher(),
isRegistering.eraseToAnyPublisher()
)
.map { !$0 && !$1 }
.assign(to: \.value, on: self.isIdle)
.store(in: &disposeBag)
2021-02-02 08:38:54 +01:00
domain
.map { $0 != nil }
.assign(to: \.value, on: isDomainValid)
2021-02-02 08:38:54 +01:00
.store(in: &disposeBag)
2021-02-02 12:31:10 +01:00
}
}
extension AuthenticationViewModel {
static func parseDomain(from input: String) -> String? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return nil }
let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed
guard let url = URL(string: urlString),
let host = url.host else {
return nil
}
let components = host.components(separatedBy: ".")
guard !components.contains(where: { $0.isEmpty }) else { return nil }
guard components.count >= 2 else { return nil }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: input host: %s", ((#file as NSString).lastPathComponent), #line, #function, host)
return host
}
}
2021-02-05 08:58:48 +01:00
extension AuthenticationViewModel {
enum AuthenticationError: Error, LocalizedError {
case badCredentials
case registrationClosed
var errorDescription: String? {
switch self {
case .badCredentials: return "Bad Credentials"
case .registrationClosed: return "Registration Closed"
}
}
var failureReason: String? {
switch self {
case .badCredentials: return "Credentials invalid."
case .registrationClosed: return "Server disallow registration."
}
}
var helpAnchor: String? {
switch self {
case .badCredentials: return "Please try again."
case .registrationClosed: return "Please try another domain."
}
}
}
}
2021-02-02 12:31:10 +01:00
extension AuthenticationViewModel {
struct AuthenticateInfo {
let domain: String
let clientID: String
let clientSecret: String
2021-02-05 08:58:48 +01:00
let authorizeURL: URL
init?(domain: String, application: Mastodon.Entity.Application) {
self.domain = domain
guard let clientID = application.clientID,
let clientSecret = application.clientSecret else { return nil }
self.clientID = clientID
self.clientSecret = clientSecret
self.authorizeURL = {
let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID)
let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query)
return url
}()
}
2021-02-02 12:31:10 +01:00
}
func authenticate(info: AuthenticateInfo, pinCodePublisher: PassthroughSubject<String, Never>) {
pinCodePublisher
.handleEvents(receiveOutput: { [weak self] _ in
guard let self = self else { return }
self.isAuthenticating.value = true
self.mastodonPinBasedAuthenticationViewController?.dismiss(animated: true, completion: nil)
self.mastodonPinBasedAuthenticationViewController = nil
})
.compactMap { [weak self] code -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in
guard let self = self else { return nil }
return self.context.apiService
.userAccessToken(
domain: info.domain,
clientID: info.clientID,
clientSecret: info.clientSecret,
code: code
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
let token = response.value
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in success. Token: %s", ((#file as NSString).lastPathComponent), #line, #function, token.accessToken)
return AuthenticationViewModel.verifyAndSaveAuthentication(
context: self.context,
info: info,
2021-02-03 09:01:08 +01:00
userToken: token
2021-02-02 12:31:10 +01:00
)
}
.eraseToAnyPublisher()
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
self.isAuthenticating.value = false
self.error.value = error
case .finished:
break
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
let account = response.value
2021-02-03 09:01:08 +01:00
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s sign in success", ((#file as NSString).lastPathComponent), #line, #function, account.username)
self.authenticated.send((domain: info.domain, account: account))
2021-02-02 12:31:10 +01:00
}
.store(in: &self.disposeBag)
}
static func verifyAndSaveAuthentication(
context: AppContext,
info: AuthenticateInfo,
2021-02-03 09:01:08 +01:00
userToken: Mastodon.Entity.Token
2021-02-02 12:31:10 +01:00
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
2021-02-03 09:01:08 +01:00
let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken)
let managedObjectContext = context.backgroundManagedObjectContext
2021-02-02 12:31:10 +01:00
return context.apiService.accountVerifyCredentials(
domain: info.domain,
authorization: authorization
)
2021-02-03 09:01:08 +01:00
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
let account = response.value
let mastodonUserRequest = MastodonUser.sortedFetchRequest
mastodonUserRequest.predicate = MastodonUser.predicate(domain: info.domain, id: account.id)
mastodonUserRequest.fetchLimit = 1
guard let mastodonUser = try? managedObjectContext.fetch(mastodonUserRequest).first else {
2021-02-05 08:58:48 +01:00
return Fail(error: AuthenticationError.badCredentials).eraseToAnyPublisher()
2021-02-03 09:01:08 +01:00
}
let property = MastodonAuthentication.Property(
domain: info.domain,
userID: mastodonUser.id,
username: mastodonUser.username,
appAccessToken: userToken.accessToken, // TODO: swap app token
userAccessToken: userToken.accessToken,
clientID: info.clientID,
clientSecret: info.clientSecret
)
return managedObjectContext.performChanges {
_ = APIService.CoreData.createOrMergeMastodonAuthentication(
into: managedObjectContext,
for: mastodonUser,
in: info.domain,
property: property,
networkDate: response.networkDate
)
}
.setFailureType(to: Error.self)
.tryMap { result in
switch result {
case .failure(let error): throw error
case .success: return response
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
2021-02-02 08:38:54 +01:00
}
}