From 54f8c82fcd6f55f0769e3a570be76dab49387dce Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sat, 8 Aug 2020 22:37:04 -0700 Subject: [PATCH] Refactoring --- Development Assets/DevelopmentModels.swift | 14 +- Metatext.xcodeproj/project.pbxproj | 12 ++ Shared/Databases/IdentityDatabase.swift | 7 +- Shared/MetatextApp.swift | 2 +- Shared/Services/AuthenticationService.swift | 171 ++++++++++++++++++ Shared/Services/IdentitiesService.swift | 43 +++++ Shared/Services/IdentityService.swift | 28 +-- Shared/View Models/AddIdentityViewModel.swift | 167 +---------------- Shared/View Models/RootViewModel.swift | 67 ++++--- Shared/Views/AddIdentityView.swift | 2 +- Shared/Views/RootView.swift | 5 +- .../AddIdentityViewModelTests.swift | 8 +- Tests/View Models/RootViewModelTests.swift | 11 +- 13 files changed, 310 insertions(+), 227 deletions(-) create mode 100644 Shared/Services/AuthenticationService.swift create mode 100644 Shared/Services/IdentitiesService.swift diff --git a/Development Assets/DevelopmentModels.swift b/Development Assets/DevelopmentModels.swift index a4170a3..fd6d22d 100644 --- a/Development Assets/DevelopmentModels.swift +++ b/Development Assets/DevelopmentModels.swift @@ -91,16 +91,24 @@ extension AppEnvironment { webAuthSessionType: SuccessfulMockWebAuthSession.self) } +extension IdentitiesService { + static let development = IdentitiesService(environment: .development) +} + extension IdentityService { - static let development = try! IdentityService(identityID: devIdentityID, appEnvironment: .development) + static let development = try! IdentitiesService.development.identityService(id: devIdentityID) } extension RootViewModel { - static let development = RootViewModel(environment: .development) + static let development = RootViewModel(identitiesService: .development) +} + +extension AddIdentityViewModel { + static let development = RootViewModel.development.addIdentityViewModel() } extension MainNavigationViewModel { - static let development = RootViewModel.development.mainNavigationViewModel(identityID: devIdentityID)! + static let development = RootViewModel.development.mainNavigationViewModel! } #if os(iOS) diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index b8c9393..7f8f297 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -140,6 +140,10 @@ D0EC8DC624DF842700A08489 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC424DF842700A08489 /* KeychainService.swift */; }; D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */; }; D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */; }; + D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */; }; + D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */; }; + D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; }; + D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; }; D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; }; D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; }; D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; }; @@ -245,6 +249,8 @@ D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityService.swift; sourceTree = ""; }; D0EC8DC424DF842700A08489 /* KeychainService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecretsService.swift; sourceTree = ""; }; + D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesService.swift; sourceTree = ""; }; + D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = ""; }; D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = ""; }; D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; @@ -349,6 +355,8 @@ D019E6F224DF7C9E00697C7D /* Services */ = { isa = PBXGroup; children = ( + D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */, + D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */, D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */, D0EC8DC424DF842700A08489 /* KeychainService.swift */, D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */, @@ -796,6 +804,7 @@ D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */, D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */, D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */, + D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */, D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */, D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */, D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, @@ -814,6 +823,7 @@ D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */, D0159FA324DE955900E78478 /* CustomEmojiText.swift in Sources */, D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */, + D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */, D04FD73C24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift in Sources */, D0DC175B24D0154F00A75C65 /* MastodonAPI.swift in Sources */, D0ED1BD124CF779B00B4899C /* MastodonTarget.swift in Sources */, @@ -849,6 +859,7 @@ D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */, D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */, D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */, + D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */, D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */, D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */, D0A652AE24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */, @@ -875,6 +886,7 @@ D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */, D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */, D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */, + D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */, D0159F9C24DE748C00E78478 /* SidebarNavigationView.swift in Sources */, D019E6F124DF7C2F00697C7D /* DatabaseError.swift in Sources */, D074577824D29006004758DB /* MockWebAuthSession.swift in Sources */, diff --git a/Shared/Databases/IdentityDatabase.swift b/Shared/Databases/IdentityDatabase.swift index 9faab7b..f8902d0 100644 --- a/Shared/Databases/IdentityDatabase.swift +++ b/Shared/Databases/IdentityDatabase.swift @@ -138,8 +138,11 @@ extension IdentityDatabase { .eraseToAnyPublisher() } - var mostRecentlyUsedIdentityID: UUID? { - try? databaseQueue.read(StoredIdentity.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne) + func mostRecentlyUsedIdentityIDObservation() -> AnyPublisher { + ValueObservation.tracking(StoredIdentity.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne) + .removeDuplicates() + .publisher(in: databaseQueue, scheduling: .immediate) + .eraseToAnyPublisher() } } diff --git a/Shared/MetatextApp.swift b/Shared/MetatextApp.swift index c64884e..0365340 100644 --- a/Shared/MetatextApp.swift +++ b/Shared/MetatextApp.swift @@ -25,7 +25,7 @@ struct MetatextApp: App { var body: some Scene { WindowGroup { - RootView(viewModel: RootViewModel(environment: environment)) + RootView(viewModel: RootViewModel(identitiesService: IdentitiesService(environment: environment))) } } } diff --git a/Shared/Services/AuthenticationService.swift b/Shared/Services/AuthenticationService.swift new file mode 100644 index 0000000..98d15c3 --- /dev/null +++ b/Shared/Services/AuthenticationService.swift @@ -0,0 +1,171 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine + +struct AuthenticationService { + private let environment: AppEnvironment + private let networkClient: MastodonClient + private let webAuthSessionContextProvider = WebAuthSessionContextProvider() + + init(environment: AppEnvironment) { + self.environment = environment + self.networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) + } +} + +extension AuthenticationService { + func authenticate(instanceURL: URL) -> AnyPublisher { + let identityID = UUID() + let redirectURL: URL + + do { + redirectURL = try identityID.uuidString.url(scheme: MastodonAPI.OAuth.callbackURLScheme) + } catch { + return Fail(error: error).eraseToAnyPublisher() + } + + return authorizeApp( + identityID: identityID, + instanceURL: instanceURL, + redirectURL: redirectURL, + keychainService: environment.keychainService) + .authenticationURL(instanceURL: instanceURL, redirectURL: redirectURL) + .authenticate( + webAuthSessionType: environment.webAuthSessionType, + contextProvider: webAuthSessionContextProvider, + callbackURLScheme: MastodonAPI.OAuth.callbackURLScheme) + .extractCode() + .requestAccessToken( + networkClient: networkClient, + identityID: identityID, + instanceURL: instanceURL, + redirectURL: redirectURL) + .createIdentity(id: identityID, instanceURL: instanceURL, environment: environment) + } +} + +private extension AuthenticationService { + func authorizeApp( + identityID: UUID, + instanceURL: URL, + redirectURL: URL, + keychainService: KeychainServiceType) -> AnyPublisher { + let endpoint = AppAuthorizationEndpoint.apps( + clientName: MastodonAPI.OAuth.clientName, + redirectURI: redirectURL.absoluteString, + scopes: MastodonAPI.OAuth.scopes, + website: nil) + let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) + + return networkClient.request(target) + .tryMap { + let secretsService = SecretsService(identityID: identityID, keychainService: keychainService) + try secretsService.set($0.clientId, forItem: .clientID) + try secretsService.set($0.clientSecret, forItem: .clientSecret) + + return $0 + } + .eraseToAnyPublisher() + } +} + +private extension Publisher where Output == AppAuthorization { + func authenticationURL(instanceURL: URL, redirectURL: URL) -> AnyPublisher<(AppAuthorization, URL), Error> { + tryMap { appAuthorization in + guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else { + throw URLError(.badURL) + } + + authorizationURLComponents.path = "/oauth/authorize" + authorizationURLComponents.queryItems = [ + "client_id": appAuthorization.clientId, + "scope": MastodonAPI.OAuth.scopes, + "response_type": "code", + "redirect_uri": redirectURL.absoluteString + ].map { URLQueryItem(name: $0, value: $1) } + + guard let authorizationURL = authorizationURLComponents.url else { + throw URLError(.badURL) + } + + return (appAuthorization, authorizationURL) + } + .mapError { $0 as Error } + .eraseToAnyPublisher() + } +} + +private extension Publisher where Output == (AppAuthorization, URL), Failure == Error { + func authenticate( + webAuthSessionType: WebAuthSessionType.Type, + contextProvider: WebAuthSessionContextProvider, + callbackURLScheme: String) -> AnyPublisher<(AppAuthorization, URL), Error> { + flatMap { appAuthorization, url in + webAuthSessionType.publisher( + url: url, + callbackURLScheme: callbackURLScheme, + presentationContextProvider: contextProvider) + .tryCatch { error -> AnyPublisher in + if (error as? WebAuthSessionError)?.code == .canceledLogin { + return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() + } + + throw error + } + .compactMap { $0 } + .map { (appAuthorization, $0) } + } + .eraseToAnyPublisher() + } +} + +private extension Publisher where Output == (AppAuthorization, URL) { + func extractCode() -> AnyPublisher<(AppAuthorization, String), Error> { + tryMap { appAuthorization, url -> (AppAuthorization, String) in + guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems, + let code = queryItems.first(where: { $0.name == MastodonAPI.OAuth.codeCallbackQueryItemName })?.value + else { throw MastodonAPI.OAuthError.codeNotFound } + + return (appAuthorization, code) + } + .eraseToAnyPublisher() + } +} + +private extension Publisher where Output == (AppAuthorization, String), Failure == Error { + func requestAccessToken( + networkClient: HTTPClient, + identityID: UUID, + instanceURL: URL, + redirectURL: URL) -> AnyPublisher { + flatMap { appAuthorization, code -> AnyPublisher in + let endpoint = AccessTokenEndpoint.oauthToken( + clientID: appAuthorization.clientId, + clientSecret: appAuthorization.clientSecret, + code: code, + grantType: MastodonAPI.OAuth.grantType, + scopes: MastodonAPI.OAuth.scopes, + redirectURI: redirectURL.absoluteString) + let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) + + return networkClient.request(target) + } + .eraseToAnyPublisher() + } +} + +private extension Publisher where Output == AccessToken { + func createIdentity(id: UUID, instanceURL: URL, environment: AppEnvironment) -> AnyPublisher { + tryMap { accessToken -> (UUID, URL) in + let secretsService = SecretsService(identityID: id, keychainService: environment.keychainService) + + try secretsService.set(accessToken.accessToken, forItem: .accessToken) + + return (id, instanceURL) + } + .flatMap(environment.identityDatabase.createIdentity) + .map { id } + .eraseToAnyPublisher() + } +} diff --git a/Shared/Services/IdentitiesService.swift b/Shared/Services/IdentitiesService.swift new file mode 100644 index 0000000..576c78d --- /dev/null +++ b/Shared/Services/IdentitiesService.swift @@ -0,0 +1,43 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine + +class IdentitiesService { + @Published var mostRecentlyUsedIdentityID: UUID? + + private let environment: AppEnvironment + private var cancellables = Set() + + init(environment: AppEnvironment) { + self.environment = environment + + environment.identityDatabase.mostRecentlyUsedIdentityIDObservation() + .replaceError(with: nil) + .assign(to: &$mostRecentlyUsedIdentityID) + } +} + +extension IdentitiesService { + func identityService(id: UUID) throws -> IdentityService { + try IdentityService(identityID: id, environment: environment) + } + + func authenticationService() -> AuthenticationService { + AuthenticationService(environment: environment) + } + + func deleteIdentity(id: UUID) -> AnyPublisher { + environment.identityDatabase.deleteIdentity(id: id) + .continuingIfWeakReferenceIsStillAlive(to: self) + .tryMap { _, welf -> Void in + try SecretsService( + identityID: id, + keychainService: welf.environment.keychainService) + .deleteAllItems() + + return () + } + .eraseToAnyPublisher() + } +} diff --git a/Shared/Services/IdentityService.swift b/Shared/Services/IdentityService.swift index cd057c9..47ba511 100644 --- a/Shared/Services/IdentityService.swift +++ b/Shared/Services/IdentityService.swift @@ -8,20 +8,20 @@ class IdentityService { let observationErrors: AnyPublisher private let networkClient: MastodonClient - private let appEnvironment: AppEnvironment + private let environment: AppEnvironment private let observationErrorsInput = PassthroughSubject() private var cancellables = Set() - init(identityID: UUID, appEnvironment: AppEnvironment) throws { - self.appEnvironment = appEnvironment + init(identityID: UUID, environment: AppEnvironment) throws { + self.environment = environment observationErrors = observationErrorsInput.eraseToAnyPublisher() - networkClient = MastodonClient(configuration: appEnvironment.URLSessionConfiguration) + networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) networkClient.accessToken = try SecretsService( identityID: identityID, - keychainService: appEnvironment.keychainService) + keychainService: environment.keychainService) .item(.accessToken) - let observation = appEnvironment.identityDatabase.identityObservation(id: identityID).share() + let observation = environment.identityDatabase.identityObservation(id: identityID).share() var initialIdentity: Identity? @@ -47,11 +47,15 @@ class IdentityService { extension IdentityService { var isAuthorized: Bool { networkClient.accessToken != nil } + func updateLastUse() -> AnyPublisher { + environment.identityDatabase.updateLastUsedAt(identityID: identity.id) + } + func verifyCredentials() -> AnyPublisher { networkClient.request(AccountEndpoint.verifyCredentials) .continuingIfWeakReferenceIsStillAlive(to: self) .map { ($0, $1.identity.id) } - .flatMap(appEnvironment.identityDatabase.updateAccount) + .flatMap(environment.identityDatabase.updateAccount) .eraseToAnyPublisher() } @@ -59,7 +63,7 @@ extension IdentityService { networkClient.request(PreferencesEndpoint.preferences) .continuingIfWeakReferenceIsStillAlive(to: self) .map { ($1.identity.preferences.updated(from: $0), $1.identity.id) } - .flatMap(appEnvironment.identityDatabase.updatePreferences) + .flatMap(environment.identityDatabase.updatePreferences) .eraseToAnyPublisher() } @@ -67,19 +71,19 @@ extension IdentityService { networkClient.request(InstanceEndpoint.instance) .continuingIfWeakReferenceIsStillAlive(to: self) .map { ($0, $1.identity.id) } - .flatMap(appEnvironment.identityDatabase.updateInstance) + .flatMap(environment.identityDatabase.updateInstance) .eraseToAnyPublisher() } func identitiesObservation() -> AnyPublisher<[Identity], Error> { - appEnvironment.identityDatabase.identitiesObservation() + environment.identityDatabase.identitiesObservation() } func recentIdentitiesObservation() -> AnyPublisher<[Identity], Error> { - appEnvironment.identityDatabase.recentIdentitiesObservation(excluding: identity.id) + environment.identityDatabase.recentIdentitiesObservation(excluding: identity.id) } func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher { - appEnvironment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id) + environment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id) } } diff --git a/Shared/View Models/AddIdentityViewModel.swift b/Shared/View Models/AddIdentityViewModel.swift index d9b69ef..68f3d73 100644 --- a/Shared/View Models/AddIdentityViewModel.swift +++ b/Shared/View Models/AddIdentityViewModel.swift @@ -9,49 +9,19 @@ class AddIdentityViewModel: ObservableObject { @Published private(set) var loading = false let addedIdentityID: AnyPublisher - private let environment: AppEnvironment - private let networkClient: MastodonClient - private let webAuthSessionContextProvider = WebAuthSessionContextProvider() + private let authenticationService: AuthenticationService private let addedIdentityIDInput = PassthroughSubject() private var cancellables = Set() - init(environment: AppEnvironment) { - self.environment = environment - self.networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) + init(authenticationService: AuthenticationService) { + self.authenticationService = authenticationService addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher() } func goTapped() { - let identityID = UUID() - let instanceURL: URL - let redirectURL: URL - - do { - instanceURL = try urlFieldText.url() - redirectURL = try identityID.uuidString.url(scheme: MastodonAPI.OAuth.callbackURLScheme) - } catch { - alertItem = AlertItem(error: error) - - return - } - - authorizeApp( - identityID: identityID, - instanceURL: instanceURL, - redirectURL: redirectURL, - keychainService: environment.keychainService) - .authenticationURL(instanceURL: instanceURL, redirectURL: redirectURL) - .authenticate( - webAuthSessionType: environment.webAuthSessionType, - contextProvider: webAuthSessionContextProvider, - callbackURLScheme: MastodonAPI.OAuth.callbackURLScheme) - .extractCode() - .requestAccessToken( - networkClient: networkClient, - identityID: identityID, - instanceURL: instanceURL, - redirectURL: redirectURL) - .createIdentity(id: identityID, instanceURL: instanceURL, environment: environment) + Just(urlFieldText) + .tryMap { try $0.url() } + .flatMap(authenticationService.authenticate(instanceURL:)) .assignErrorsToAlertItem(to: \.alertItem, on: self) .receive(on: RunLoop.main) .handleEvents( @@ -61,128 +31,3 @@ class AddIdentityViewModel: ObservableObject { .store(in: &cancellables) } } - -private extension AddIdentityViewModel { - private func authorizeApp( - identityID: UUID, - instanceURL: URL, - redirectURL: URL, - keychainService: KeychainServiceType) -> AnyPublisher { - let endpoint = AppAuthorizationEndpoint.apps( - clientName: MastodonAPI.OAuth.clientName, - redirectURI: redirectURL.absoluteString, - scopes: MastodonAPI.OAuth.scopes, - website: nil) - let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) - - return networkClient.request(target) - .tryMap { - let secretsService = SecretsService(identityID: identityID, keychainService: keychainService) - try secretsService.set($0.clientId, forItem: .clientID) - try secretsService.set($0.clientSecret, forItem: .clientSecret) - - return $0 - } - .eraseToAnyPublisher() - } -} - -private extension Publisher where Output == AppAuthorization { - func authenticationURL(instanceURL: URL, redirectURL: URL) -> AnyPublisher<(AppAuthorization, URL), Error> { - tryMap { appAuthorization in - guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else { - throw URLError(.badURL) - } - - authorizationURLComponents.path = "/oauth/authorize" - authorizationURLComponents.queryItems = [ - "client_id": appAuthorization.clientId, - "scope": MastodonAPI.OAuth.scopes, - "response_type": "code", - "redirect_uri": redirectURL.absoluteString - ].map { URLQueryItem(name: $0, value: $1) } - - guard let authorizationURL = authorizationURLComponents.url else { - throw URLError(.badURL) - } - - return (appAuthorization, authorizationURL) - } - .mapError { $0 as Error } - .eraseToAnyPublisher() - } -} - -private extension Publisher where Output == (AppAuthorization, URL), Failure == Error { - func authenticate( - webAuthSessionType: WebAuthSessionType.Type, - contextProvider: WebAuthSessionContextProvider, - callbackURLScheme: String) -> AnyPublisher<(AppAuthorization, URL), Error> { - flatMap { appAuthorization, url in - webAuthSessionType.publisher( - url: url, - callbackURLScheme: callbackURLScheme, - presentationContextProvider: contextProvider) - .tryCatch { error -> AnyPublisher in - if (error as? WebAuthSessionError)?.code == .canceledLogin { - return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() - } - - throw error - } - .compactMap { $0 } - .map { (appAuthorization, $0) } - } - .eraseToAnyPublisher() - } -} - -private extension Publisher where Output == (AppAuthorization, URL) { - func extractCode() -> AnyPublisher<(AppAuthorization, String), Error> { - tryMap { appAuthorization, url -> (AppAuthorization, String) in - guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems, - let code = queryItems.first(where: { $0.name == MastodonAPI.OAuth.codeCallbackQueryItemName })?.value - else { throw MastodonAPI.OAuthError.codeNotFound } - - return (appAuthorization, code) - } - .eraseToAnyPublisher() - } -} - -private extension Publisher where Output == (AppAuthorization, String), Failure == Error { - func requestAccessToken( - networkClient: HTTPClient, - identityID: UUID, - instanceURL: URL, - redirectURL: URL) -> AnyPublisher { - flatMap { appAuthorization, code -> AnyPublisher in - let endpoint = AccessTokenEndpoint.oauthToken( - clientID: appAuthorization.clientId, - clientSecret: appAuthorization.clientSecret, - code: code, - grantType: MastodonAPI.OAuth.grantType, - scopes: MastodonAPI.OAuth.scopes, - redirectURI: redirectURL.absoluteString) - let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) - - return networkClient.request(target) - } - .eraseToAnyPublisher() - } -} - -private extension Publisher where Output == AccessToken { - func createIdentity(id: UUID, instanceURL: URL, environment: AppEnvironment) -> AnyPublisher { - tryMap { accessToken -> (UUID, URL) in - let secretsService = SecretsService(identityID: id, keychainService: environment.keychainService) - - try secretsService.set(accessToken.accessToken, forItem: .accessToken) - - return (id, instanceURL) - } - .flatMap(environment.identityDatabase.createIdentity) - .map { id } - .eraseToAnyPublisher() - } -} diff --git a/Shared/View Models/RootViewModel.swift b/Shared/View Models/RootViewModel.swift index d1ef48e..093c338 100644 --- a/Shared/View Models/RootViewModel.swift +++ b/Shared/View Models/RootViewModel.swift @@ -4,57 +4,54 @@ import Foundation import Combine class RootViewModel: ObservableObject { - @Published private(set) var identityID: UUID? - private let environment: AppEnvironment + @Published private(set) var mainNavigationViewModel: MainNavigationViewModel? + + private let identitiesService: IdentitiesService private var cancellables = Set() - init(environment: AppEnvironment) { - self.environment = environment - identityID = environment.identityDatabase.mostRecentlyUsedIdentityID + init(identitiesService: IdentitiesService) { + self.identitiesService = identitiesService + + newIdentitySelected(id: identitiesService.mostRecentlyUsedIdentityID) } } extension RootViewModel { - func newIdentitySelected(id: UUID) { - identityID = id + func newIdentitySelected(id: UUID?) { + guard let id = id else { + mainNavigationViewModel = nil - environment.identityDatabase - .updateLastUsedAt(identityID: id) + return + } + + let identityService: IdentityService + + do { + identityService = try identitiesService.identityService(id: id) + } catch { + return + } + + identityService.observationErrors + .receive(on: RunLoop.main) + .map { [weak self] _ in self?.identitiesService.mostRecentlyUsedIdentityID } + .sink(receiveValue: newIdentitySelected(id:)) + .store(in: &cancellables) + + identityService.updateLastUse() .sink(receiveCompletion: { _ in }, receiveValue: {}) .store(in: &cancellables) + + mainNavigationViewModel = MainNavigationViewModel(identityService: identityService) } func deleteIdentity(id: UUID) { - environment.identityDatabase.deleteIdentity(id: id) - .continuingIfWeakReferenceIsStillAlive(to: self) - .tryMap { - try SecretsService( - identityID: id, - keychainService: $1.environment.keychainService) - .deleteAllItems() - } + identitiesService.deleteIdentity(id: id) .sink(receiveCompletion: { _ in }, receiveValue: {}) .store(in: &cancellables) } func addIdentityViewModel() -> AddIdentityViewModel { - AddIdentityViewModel(environment: environment) - } - - func mainNavigationViewModel(identityID: UUID) -> MainNavigationViewModel? { - let identityService: IdentityService - - do { - identityService = try IdentityService(identityID: identityID, appEnvironment: environment) - } catch { - return nil - } - - identityService.observationErrors - .receive(on: RunLoop.main) - .map { [weak self] _ in self?.environment.identityDatabase.mostRecentlyUsedIdentityID } - .assign(to: &$identityID) - - return MainNavigationViewModel(identityService: identityService) + AddIdentityViewModel(authenticationService: identitiesService.authenticationService()) } } diff --git a/Shared/Views/AddIdentityView.swift b/Shared/Views/AddIdentityView.swift index 80f9a63..888252b 100644 --- a/Shared/Views/AddIdentityView.swift +++ b/Shared/Views/AddIdentityView.swift @@ -60,7 +60,7 @@ private extension View { #if DEBUG struct AddAccountView_Previews: PreviewProvider { static var previews: some View { - AddIdentityView(viewModel: AddIdentityViewModel(environment: .development)) + AddIdentityView(viewModel: .development) } } #endif diff --git a/Shared/Views/RootView.swift b/Shared/Views/RootView.swift index 1830b87..606f8dc 100644 --- a/Shared/Views/RootView.swift +++ b/Shared/Views/RootView.swift @@ -12,10 +12,9 @@ struct RootView: View { @StateObject var viewModel: RootViewModel var body: some View { - if let id = viewModel.identityID, - let mainNavigationViewModel = viewModel.mainNavigationViewModel(identityID: id) { + if let mainNavigationViewModel = viewModel.mainNavigationViewModel { Self.mainNavigation(mainNavigationViewModel: mainNavigationViewModel) - .id(id) + .id(UUID()) .environmentObject(viewModel) .transition(.opacity) } else { diff --git a/Tests/View Models/AddIdentityViewModelTests.swift b/Tests/View Models/AddIdentityViewModelTests.swift index 273e9b4..fb485bc 100644 --- a/Tests/View Models/AddIdentityViewModelTests.swift +++ b/Tests/View Models/AddIdentityViewModelTests.swift @@ -8,7 +8,7 @@ import CombineExpectations class AddIdentityViewModelTests: XCTestCase { func testAddIdentity() throws { let environment = AppEnvironment.fresh() - let sut = AddIdentityViewModel(environment: environment) + let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment)) let addedIDRecorder = sut.addedIdentityID.record() sut.urlFieldText = "https://mastodon.social" @@ -33,7 +33,7 @@ class AddIdentityViewModelTests: XCTestCase { func testAddIdentityWithoutScheme() throws { let environment = AppEnvironment.fresh() - let sut = AddIdentityViewModel(environment: environment) + let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment)) let addedIDRecorder = sut.addedIdentityID.record() sut.urlFieldText = "mastodon.social" @@ -47,7 +47,7 @@ class AddIdentityViewModelTests: XCTestCase { } func testInvalidURL() throws { - let sut = AddIdentityViewModel(environment: .fresh()) + let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: .fresh())) let recorder = sut.$alertItem.record() XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) @@ -62,7 +62,7 @@ class AddIdentityViewModelTests: XCTestCase { func testDoesNotAlertCanceledLogin() throws { let environment = AppEnvironment.fresh(webAuthSessionType: CanceledLoginMockWebAuthSession.self) - let sut = AddIdentityViewModel(environment: environment) + let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment)) let recorder = sut.$alertItem.record() XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) diff --git a/Tests/View Models/RootViewModelTests.swift b/Tests/View Models/RootViewModelTests.swift index 68458b1..a058e95 100644 --- a/Tests/View Models/RootViewModelTests.swift +++ b/Tests/View Models/RootViewModelTests.swift @@ -9,8 +9,10 @@ class RootViewModelTests: XCTestCase { var cancellables = Set() func testAddIdentity() throws { - let sut = RootViewModel(environment: .fresh()) - let recorder = sut.$identityID.record() + let environment = AppEnvironment.fresh() + let sut = RootViewModel( + identitiesService: IdentitiesService(environment: environment)) + let recorder = sut.$mainNavigationViewModel.record() XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) @@ -23,9 +25,8 @@ class RootViewModelTests: XCTestCase { addIdentityViewModel.urlFieldText = "https://mastodon.social" addIdentityViewModel.goTapped() - let identityID = try wait(for: recorder.next(), timeout: 1)! + let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)! - XCTAssertNotNil(identityID) - XCTAssertNotNil(sut.mainNavigationViewModel(identityID: identityID)) + XCTAssertNotNil(mainNavigationViewModel) } }