diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index ca67b1820..5cd582bf1 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -25,6 +25,20 @@ + + + + + + + + + + + + + + @@ -38,6 +52,7 @@ + @@ -97,9 +112,10 @@ - + + \ No newline at end of file diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/CoreDataStack/Entity/MastodonAuthentication.swift new file mode 100644 index 000000000..e58c2e877 --- /dev/null +++ b/CoreDataStack/Entity/MastodonAuthentication.swift @@ -0,0 +1,161 @@ +// +// MastodonAuthentication.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import Foundation +import CoreData + +final public class MastodonAuthentication: NSManagedObject { + + public typealias ID = UUID + + @NSManaged public private(set) var identifier: ID + + @NSManaged public private(set) var domain: String + @NSManaged public private(set) var userID: String + @NSManaged public private(set) var username: String + + @NSManaged public private(set) var appAccessToken: String + @NSManaged public private(set) var userAccessToken: String + @NSManaged public private(set) var clientID: String + @NSManaged public private(set) var clientSecret: String + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var activedAt: Date + + // one-to-one relationship + @NSManaged public private(set) var user: MastodonUser + +} + +extension MastodonAuthentication { + + public override func awakeFromInsert() { + super.awakeFromInsert() + identifier = UUID() + + let now = Date() + createdAt = now + updatedAt = now + activedAt = now + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + user: MastodonUser + ) -> MastodonAuthentication { + let authentication: MastodonAuthentication = context.insertObject() + + authentication.domain = property.domain + authentication.userID = property.userID + authentication.username = property.username + authentication.appAccessToken = property.appAccessToken + authentication.userAccessToken = property.userAccessToken + authentication.clientID = property.clientID + authentication.clientSecret = property.clientSecret + + authentication.user = user + + return authentication + } + + public func update(username: String) { + if self.username != username { + self.username = username + } + } + public func update(appAccessToken: String) { + if self.appAccessToken != appAccessToken { + self.appAccessToken = appAccessToken + } + } + public func update(userAccessToken: String) { + if self.userAccessToken != userAccessToken { + self.userAccessToken = userAccessToken + } + } + public func update(clientID: String) { + if self.clientID != clientID { + self.clientID = clientID + } + } + public func update(clientSecret: String) { + if self.clientSecret != clientSecret { + self.clientSecret = clientSecret + } + } + + public func update(activedAt: Date) { + if self.activedAt != activedAt { + self.activedAt = activedAt + } + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + +} + +extension MastodonAuthentication { + public struct Property { + + public let domain: String + public let userID: String + public let username: String + public let appAccessToken: String + public let userAccessToken: String + public let clientID: String + public let clientSecret: String + + public init( + domain: String, + userID: String, + username: String, + appAccessToken: String, + userAccessToken: String, + clientID: String, + clientSecret: String + ) { + self.domain = domain + self.userID = userID + self.username = username + self.appAccessToken = appAccessToken + self.userAccessToken = userAccessToken + self.clientID = clientID + self.clientSecret = clientSecret + } + + } +} + +extension MastodonAuthentication: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \MastodonAuthentication.createdAt, ascending: false)] + } +} + +extension MastodonAuthentication { + + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.domain), domain) + } + + static func predicate(userID: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.userID), userID) + } + + public static func predicate(domain: String, userID: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonAuthentication.predicate(domain: domain), + MastodonAuthentication.predicate(userID: userID) + ]) + } + +} diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 338acd514..8ac22c0bf 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -8,12 +8,14 @@ import CoreData import Foundation -public final class MastodonUser: NSManagedObject { +final public class MastodonUser: NSManagedObject { + public typealias ID = String + @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var domain: String - @NSManaged public private(set) var id: String + @NSManaged public private(set) var id: ID @NSManaged public private(set) var acct: String @NSManaged public private(set) var username: String @NSManaged public private(set) var displayName: String @@ -25,6 +27,7 @@ public final class MastodonUser: NSManagedObject { // one-to-one relationship @NSManaged public private(set) var pinnedToot: Toot? + @NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication? // one-to-many relationship @NSManaged public private(set) var toots: Set? @@ -36,11 +39,13 @@ public final class MastodonUser: NSManagedObject { @NSManaged public private(set) var bookmarked: Set? @NSManaged public private(set) var retweets: Set? + } -public extension MastodonUser { +extension MastodonUser { + @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property ) -> MastodonUser { @@ -61,6 +66,38 @@ public extension MastodonUser { return user } + + + public func update(acct: String) { + if self.acct != acct { + self.acct = acct + } + } + public func update(username: String) { + if self.username != username { + self.username = username + } + } + public func update(displayName: String) { + if self.displayName != displayName { + self.displayName = displayName + } + } + public func update(avatar: String) { + if self.avatar != avatar { + self.avatar = avatar + } + } + public func update(avatarStatic: String?) { + if self.avatarStatic != avatarStatic { + self.avatarStatic = avatarStatic + } + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } public extension MastodonUser { @@ -108,3 +145,44 @@ extension MastodonUser: Managed { return [NSSortDescriptor(keyPath: \MastodonUser.createdAt, ascending: false)] } } + +extension MastodonUser { + + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.domain), domain) + } + + static func predicate(id: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.id), id) + } + + public static func predicate(domain: String, id: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonUser.predicate(domain: domain), + MastodonUser.predicate(id: id) + ]) + } + + static func predicate(ids: [String]) -> NSPredicate { + return NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), ids) + } + + public static func predicate(domain: String, ids: [String]) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonUser.predicate(domain: domain), + MastodonUser.predicate(ids: ids) + ]) + } + + static func predicate(username: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.username), username) + } + + public static func predicate(domain: String, username: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonUser.predicate(domain: domain), + MastodonUser.predicate(username: username) + ]) + } + +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 1c9c6c724..fb5c211e2 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -54,6 +54,13 @@ DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; }; DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; + DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; + DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; + DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */; }; + DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */; }; + DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */; }; + DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; + DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -193,6 +200,13 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastodonUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; + DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; + DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonUser.swift"; sourceTree = ""; }; + DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUser.swift; sourceTree = ""; }; + DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthentication.swift; sourceTree = ""; }; + DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; + DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -322,13 +336,8 @@ 2D61335525C1886800CAE157 /* Service */ = { isa = PBXGroup; children = ( - 2D61335D25C1894B00CAE157 /* APIService.swift */, - DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, - DB98336A25C9420100AD9700 /* APIService+App.swift */, - DB98337025C9443200AD9700 /* APIService+Authentication.swift */, - DB98339B25C96DE600AD9700 /* APIService+Account.swift */, - 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, - 2D61335625C1887F00CAE157 /* Persist */, + DB45FB0425CA87B4005A8AC7 /* APIService */, + DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, ); path = Service; sourceTree = ""; @@ -522,6 +531,30 @@ path = MastodonUITests; sourceTree = ""; }; + DB45FB0425CA87B4005A8AC7 /* APIService */ = { + isa = PBXGroup; + children = ( + 2D61335625C1887F00CAE157 /* Persist */, + DB45FB0925CA87BC005A8AC7 /* CoreData */, + 2D61335D25C1894B00CAE157 /* APIService.swift */, + DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, + DB98336A25C9420100AD9700 /* APIService+App.swift */, + DB98337025C9443200AD9700 /* APIService+Authentication.swift */, + DB98339B25C96DE600AD9700 /* APIService+Account.swift */, + 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, + ); + path = APIService; + sourceTree = ""; + }; + DB45FB0925CA87BC005A8AC7 /* CoreData */ = { + isa = PBXGroup; + children = ( + DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, + DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, + ); + path = CoreData; + sourceTree = ""; + }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -566,6 +599,7 @@ 2D927F0725C7E9A8004F19B8 /* Tag.swift */, 2D927F0D25C7E9C9004F19B8 /* History.swift */, 2D927F1325C7EDD9004F19B8 /* Emoji.swift */, + DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */, ); path = Entity; sourceTree = ""; @@ -621,13 +655,16 @@ DB8AF56225C138BC002E6C99 /* Extension */ = { isa = PBXGroup; children = ( + DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, 2D42FF6A25C817D2004A627A /* MastodonContent.swift */, + DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, + DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, ); path = Extension; sourceTree = ""; @@ -998,10 +1035,12 @@ 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, + DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, + DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, @@ -1014,14 +1053,17 @@ DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, + DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, 2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, + DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, + DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, @@ -1034,6 +1076,7 @@ DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, + DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1069,6 +1112,7 @@ DB89BA4425C1165F008580ED /* Managed.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, + DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */, 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 0c8388901..9418b6383 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -39,6 +39,8 @@ extension SceneCoordinator { enum Scene { case authentication(viewModel: AuthenticationViewModel) case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel) + + case alertController(alertController: UIAlertController) } } @@ -118,6 +120,15 @@ private extension SceneCoordinator { let _viewController = MastodonPinBasedAuthenticationViewController() _viewController.viewModel = viewModel viewController = _viewController + case .alertController(let alertController): + if let popoverPresentationController = alertController.popoverPresentationController { + assert( + popoverPresentationController.sourceView != nil || + popoverPresentationController.sourceRect != .zero || + popoverPresentationController.barButtonItem != nil + ) + } + viewController = alertController } setupDependency(for: viewController as? NeedsDependency) diff --git a/Mastodon/Extension/MastodonUser.swift b/Mastodon/Extension/MastodonUser.swift new file mode 100644 index 000000000..1f6d41839 --- /dev/null +++ b/Mastodon/Extension/MastodonUser.swift @@ -0,0 +1,26 @@ +// +// MastodonUser.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension MastodonUser.Property { + init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) { + self.init( + id: entity.id, + domain: domain, + acct: entity.acct, + username: entity.username, + displayName: entity.displayName, + avatar: entity.avatar, + avatarStatic: entity.avatarStatic, + createdAt: entity.createdAt, + networkDate: networkDate + ) + } +} diff --git a/Mastodon/Extension/UIAlertController.swift b/Mastodon/Extension/UIAlertController.swift new file mode 100644 index 000000000..7abe20cbd --- /dev/null +++ b/Mastodon/Extension/UIAlertController.swift @@ -0,0 +1,37 @@ +// +// UIAlertController.swift +// Mastodon +// + +import UIKit + +// Reference: +// https://nshipster.com/swift-foundation-error-protocols/ +extension UIAlertController { + convenience init( + _ error: Error, + preferredStyle: UIAlertController.Style + ) { + let title: String + let message: String? + if let error = error as? LocalizedError { + title = error.errorDescription ?? "Unknown Error" + message = [ + error.failureReason, + error.recoverySuggestion + ] + .compactMap { $0 } + .joined(separator: " ") + } else { + title = "Internal Error" + message = error.localizedDescription + } + + self.init( + title: title, + message: message, + preferredStyle: preferredStyle + ) + } +} + diff --git a/Mastodon/Extension/UIBarButtonItem.swift b/Mastodon/Extension/UIBarButtonItem.swift new file mode 100644 index 000000000..8a0630f03 --- /dev/null +++ b/Mastodon/Extension/UIBarButtonItem.swift @@ -0,0 +1,20 @@ +// +// UIBarButtonItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import UIKit + +extension UIBarButtonItem { + + static var activityIndicatorBarButtonItem: UIBarButtonItem { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + let barButtonItem = UIBarButtonItem(customView: activityIndicatorView) + activityIndicatorView.startAnimating() + return barButtonItem + } + +} + diff --git a/Mastodon/Scene/Authentication/AuthenticationViewController.swift b/Mastodon/Scene/Authentication/AuthenticationViewController.swift index dadad28a0..42dc854d6 100644 --- a/Mastodon/Scene/Authentication/AuthenticationViewController.swift +++ b/Mastodon/Scene/Authentication/AuthenticationViewController.swift @@ -18,7 +18,6 @@ final class AuthenticationViewController: UIViewController, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var viewModel: AuthenticationViewModel! - var mastodonPinBasedAuthenticationViewController: UIViewController? let domainTextField: UITextField = { let textField = UITextField() @@ -30,7 +29,7 @@ final class AuthenticationViewController: UIViewController, NeedsDependency { }() private(set) lazy var signInBarButtonItem = UIBarButtonItem(title: "Sign In", style: .plain, target: self, action: #selector(AuthenticationViewController.signInBarButtonItemPressed(_:))) - + let activityIndicatorBarButtonItem = UIBarButtonItem.activityIndicatorBarButtonItem } extension AuthenticationViewController { @@ -59,10 +58,59 @@ extension AuthenticationViewController { .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 } + self.navigationItem.rightBarButtonItem = isAuthenticating ? self.activityIndicatorBarButtonItem : self.signInBarButtonItem + } + .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.isSignInButtonEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: signInBarButtonItem) .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) } override func viewWillAppear(_ animated: Bool) { @@ -81,7 +129,51 @@ extension AuthenticationViewController { // TODO: alert error return } - viewModel.signInAction.send(domain) + guard !viewModel.isAuthenticating.value else { return } + viewModel.isAuthenticating.value = true + context.apiService.createApplication(domain: domain) + .tryMap { response -> AuthenticationViewModel.AuthenticateInfo in + let application = response.value + guard let clientID = application.clientID, + let clientSecret = application.clientSecret else { + throw APIService.APIError.explicit(.badResponse) + } + let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID) + let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query) + return AuthenticationViewModel.AuthenticateInfo( + domain: domain, + clientID: clientID, + clientSecret: clientSecret, + url: url + ) + } + .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.url) + 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) } } diff --git a/Mastodon/Scene/Authentication/AuthenticationViewModel.swift b/Mastodon/Scene/Authentication/AuthenticationViewModel.swift index 264ed487c..a56a4387a 100644 --- a/Mastodon/Scene/Authentication/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Authentication/AuthenticationViewModel.swift @@ -7,6 +7,8 @@ import os.log import UIKit +import CoreData +import CoreDataStack import Combine import MastodonSDK @@ -17,21 +19,24 @@ final class AuthenticationViewModel { // input let context: AppContext let coordinator: SceneCoordinator + let isAuthenticationExist: Bool let input = CurrentValueSubject("") - let signInAction = PassthroughSubject() // output + let viewHierarchyShouldReset: Bool let domain = CurrentValueSubject(nil) let isSignInButtonEnabled = CurrentValueSubject(false) let isAuthenticating = CurrentValueSubject(false) - let authenticated = PassthroughSubject() + let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>() let error = CurrentValueSubject(nil) - private var mastodonPinBasedAuthenticationViewController: UIViewController? + var mastodonPinBasedAuthenticationViewController: UIViewController? - init(context: AppContext, coordinator: SceneCoordinator) { + init(context: AppContext, coordinator: SceneCoordinator, isAuthenticationExist: Bool) { self.context = context self.coordinator = coordinator + self.isAuthenticationExist = isAuthenticationExist + self.viewHierarchyShouldReset = isAuthenticationExist input .map { input in @@ -44,8 +49,11 @@ final class AuthenticationViewModel { return nil } let components = host.components(separatedBy: ".") - guard (components.filter { !$0.isEmpty }).count >= 2 else { return nil } + 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: iput host: %s", ((#file as NSString).lastPathComponent), #line, #function, host) + return host } .assign(to: \.value, on: domain) @@ -55,60 +63,6 @@ final class AuthenticationViewModel { .map { $0 != nil } .assign(to: \.value, on: isSignInButtonEnabled) .store(in: &disposeBag) - - signInAction - .handleEvents(receiveOutput: { [weak self] _ in - // trigger state change - guard let self = self else { return } - self.isAuthenticating.value = true - }) - .flatMap { domain in - context.apiService.createApplication(domain: domain) - .retry(3) - .tryMap { response -> AuthenticateInfo in - let application = response.value - guard let clientID = application.clientID, - let clientSecret = application.clientSecret else { - throw APIService.APIError.explicit(.badResponse) - } - let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID) - let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query) - return AuthenticateInfo( - domain: domain, - clientID: clientID, - clientSecret: clientSecret, - url: url - ) - } - } - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - // trigger state update - self.isAuthenticating.value = false - - switch completion { - case .failure(let error): - // TODO: handle 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.error.value = error - case .finished: - break - } - } receiveValue: { [weak self] info in - guard let self = self else { return } - let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.url) - self.authenticate( - info: info, - pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher - ) - self.mastodonPinBasedAuthenticationViewController = self.coordinator.present( - scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel), - from: nil, - transition: .modal(animated: true, completion: nil) - ) - } - .store(in: &disposeBag) } } @@ -145,7 +99,7 @@ extension AuthenticationViewModel { return AuthenticationViewModel.verifyAndSaveAuthentication( context: self.context, info: info, - token: token + userToken: token ) } .eraseToAnyPublisher() @@ -165,7 +119,9 @@ extension AuthenticationViewModel { } receiveValue: { [weak self] response in guard let self = self else { return } let account = response.value - // TODO: + 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)) } .store(in: &self.disposeBag) } @@ -173,14 +129,52 @@ extension AuthenticationViewModel { static func verifyAndSaveAuthentication( context: AppContext, info: AuthenticateInfo, - token: Mastodon.Entity.Token + userToken: Mastodon.Entity.Token ) -> AnyPublisher, Error> { - let authorization = Mastodon.API.OAuth.Authorization(accessToken: token.accessToken) + let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken) + let managedObjectContext = context.backgroundManagedObjectContext + return context.apiService.accountVerifyCredentials( domain: info.domain, authorization: authorization ) - // TODO: add persist logic + .flatMap { response -> AnyPublisher, 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 { + return Fail(error: APIService.APIError.explicit(.badCredentials)).eraseToAnyPublisher() + } + + 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() } } diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 2d6c43136..8c975ef47 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -88,6 +88,26 @@ extension MainTabBarController { let tabBarAppearance = UITabBarAppearance() tabBarAppearance.configureWithDefaultBackground() tabBar.standardAppearance = tabBarAppearance + + context.apiService.error + .receive(on: DispatchQueue.main) + .sink { [weak self] error in + guard let self = self, let coordinator = self.coordinator else { return } + switch error { + case .implicit: + break + case .explicit: + let alertController = UIAlertController(error, preferredStyle: .alert) + let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) + alertController.addAction(okAction) + coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + } + .store(in: &disposeBag) #if DEBUG // selectedIndex = 1 diff --git a/Mastodon/Service/APIService+APIError.swift b/Mastodon/Service/APIService+APIError.swift deleted file mode 100644 index 2bb56cf2c..000000000 --- a/Mastodon/Service/APIService+APIError.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// APIService+Error.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-2-2. -// - -import UIKit -import MastodonSDK - -extension APIService { - enum APIError: Error { - - case implicit(ErrorReason) - case explicit(ErrorReason) - - enum ErrorReason { - // application internal error - case authenticationMissing - case badRequest - case badResponse - case requestThrottle - - // Server API error - case mastodonAPIError(Mastodon.API.Error) - } - - } -} diff --git a/Mastodon/Service/APIService+Account.swift b/Mastodon/Service/APIService+Account.swift deleted file mode 100644 index 1a9ad08b5..000000000 --- a/Mastodon/Service/APIService+Account.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// APIService+Account.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021/2/2. -// - -import Foundation -import Combine -import MastodonSDK - -extension APIService { - - func accountVerifyCredentials( - domain: String, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - return Mastodon.API.Account.verifyCredentials( - session: session, - domain: domain, - authorization: authorization - ) - } -} diff --git a/Mastodon/Service/APIService/APIService+APIError.swift b/Mastodon/Service/APIService/APIService+APIError.swift new file mode 100644 index 000000000..9a72e0e4f --- /dev/null +++ b/Mastodon/Service/APIService/APIService+APIError.swift @@ -0,0 +1,91 @@ +// +// APIService+Error.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-2-2. +// + +import UIKit +import MastodonSDK + +extension APIService { + enum APIError: Error { + + case implicit(ErrorReason) + case explicit(ErrorReason) + + enum ErrorReason { + // application internal error + case authenticationMissing + case badCredentials + case badRequest + case badResponse + case requestThrottle + + // Server API error + case mastodonAPIError(Mastodon.API.Error) + } + + private var errorReason: ErrorReason { + switch self { + case .implicit(let errorReason): return errorReason + case .explicit(let errorReason): return errorReason + } + } + + } +} + +// MARK: - LocalizedError +extension APIService.APIError: LocalizedError { + + var errorDescription: String? { + switch errorReason { + case .authenticationMissing: return "Fail to Authenticatie" + case .badCredentials: return "Bad Credentials" + case .badRequest: return "Bad Request" + case .badResponse: return "Bad Response" + case .requestThrottle: return "Request Throttled" + case .mastodonAPIError(let error): + guard let responseError = error.mastodonError else { + guard error.httpResponseStatus != .ok else { + return "Unknown Error" + } + return error.httpResponseStatus.reasonPhrase + } + + return responseError.errorDescription + } + } + + var failureReason: String? { + switch errorReason { + case .authenticationMissing: return "Account credential not found." + case .badCredentials: return "Credentials invalid." + case .badRequest: return "Request invalid." + case .badResponse: return "Response invalid." + case .requestThrottle: return "Request too frequency." + case .mastodonAPIError(let error): + guard let responseError = error.mastodonError else { + return nil + } + return responseError.failureReason + } + } + + var helpAnchor: String? { + switch errorReason { + case .authenticationMissing: return "Please request after authenticated." + case .badCredentials: return "Please try again.." + case .badRequest: return "Please try again." + case .badResponse: return "Please try again." + case .requestThrottle: return "Please try again later." + case .mastodonAPIError(let error): + guard let responseError = error.mastodonError else { + return nil + } + return responseError.helpAnchor + } + } + +} diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift new file mode 100644 index 000000000..1fcf212b6 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -0,0 +1,46 @@ +// +// APIService+Account.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/2. +// + +import Foundation +import Combine +import CommonOSLog +import MastodonSDK + +extension APIService { + + func accountVerifyCredentials( + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.verifyCredentials( + session: session, + domain: domain, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + let account = response.value + + return self.backgroundManagedObjectContext.performChanges { + let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser( + into: self.backgroundManagedObjectContext, + for: nil, + in: domain, + entity: account, + networkDate: response.networkDate, + log: log) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService+App.swift b/Mastodon/Service/APIService/APIService+App.swift similarity index 100% rename from Mastodon/Service/APIService+App.swift rename to Mastodon/Service/APIService/APIService+App.swift diff --git a/Mastodon/Service/APIService+Authentication.swift b/Mastodon/Service/APIService/APIService+Authentication.swift similarity index 100% rename from Mastodon/Service/APIService+Authentication.swift rename to Mastodon/Service/APIService/APIService+Authentication.swift diff --git a/Mastodon/Service/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift similarity index 100% rename from Mastodon/Service/APIService+PublicTimeline.swift rename to Mastodon/Service/APIService/APIService+PublicTimeline.swift diff --git a/Mastodon/Service/APIService.swift b/Mastodon/Service/APIService/APIService.swift similarity index 94% rename from Mastodon/Service/APIService.swift rename to Mastodon/Service/APIService/APIService.swift index e79fb1176..36778a3b2 100644 --- a/Mastodon/Service/APIService.swift +++ b/Mastodon/Service/APIService/APIService.swift @@ -25,6 +25,8 @@ final class APIService { // input let backgroundManagedObjectContext: NSManagedObjectContext + // output + let error = PassthroughSubject() init(backgroundManagedObjectContext: NSManagedObjectContext) { self.backgroundManagedObjectContext = backgroundManagedObjectContext diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonAuthentication.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonAuthentication.swift new file mode 100644 index 000000000..15624f71d --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonAuthentication.swift @@ -0,0 +1,76 @@ +// +// APIService+CoreData+MastodonAuthentication.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeMastodonAuthentication( + into managedObjectContext: NSManagedObjectContext, + for authenticateMastodonUser: MastodonUser, + in domain: String, + property: MastodonAuthentication.Property, + networkDate: Date + ) -> (mastodonAuthentication: MastodonAuthentication, isCreated: Bool) { + // fetch old mastodon authentication + let oldMastodonAuthentication: MastodonAuthentication? = { + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain, userID: property.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldMastodonAuthentication = oldMastodonAuthentication { + // merge old mastodon authentication + APIService.CoreData.mergeMastodonAuthentication( + for: authenticateMastodonUser, + old: oldMastodonAuthentication, + in: domain, + property: property, + networkDate: networkDate + ) + return (oldMastodonAuthentication, false) + } else { + let mastodonAuthentication = MastodonAuthentication.insert( + into: managedObjectContext, + property: property, + user: authenticateMastodonUser + ) + return (mastodonAuthentication, true) + } + } + + static func mergeMastodonAuthentication( + for authenticateMastodonUser: MastodonUser, + old authentication: MastodonAuthentication, + in domain: String, + property: MastodonAuthentication.Property, + networkDate: Date + ) { + guard networkDate > authentication.updatedAt else { return } + + + authentication.update(username: property.username) + authentication.update(appAccessToken: property.appAccessToken) + authentication.update(userAccessToken: property.userAccessToken) + authentication.update(clientID: property.clientID) + authentication.update(clientSecret: property.clientSecret) + + authentication.didUpdate(at: networkDate) + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift new file mode 100644 index 000000000..4f35a54c1 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -0,0 +1,86 @@ +// +// APIService+CoreData+MastodonUser.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeMastodonUser( + into managedObjectContext: NSManagedObjectContext, + for requestMastodonUser: MastodonUser?, + in domain: String, + entity: Mastodon.Entity.Account, + networkDate: Date, + log: OSLog + ) -> (user: MastodonUser, isCreated: Bool) { + let processEntityTaskSignpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "process mastodon user %{public}s", entity.id) + defer { + os_signpost(.end, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "process msstodon user %{public}s", entity.id) + } + + // fetch old mastodon user + let oldMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: entity.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldMastodonUser = oldMastodonUser { + // merge old mastodon usre + APIService.CoreData.mergeMastodonUser( + for: requestMastodonUser, + old: oldMastodonUser, + in: domain, + entity: entity, + networkDate: networkDate + ) + return (oldMastodonUser, false) + } else { + let mastodonUserProperty = MastodonUser.Property(entity: entity, domain: domain, networkDate: networkDate) + let mastodonUser = MastodonUser.insert( + into: managedObjectContext, + property: mastodonUserProperty + ) + + os_signpost(.event, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "did insert new mastodon user %{public}s: name %s", mastodonUser.identifier, mastodonUser.username) + return (mastodonUser, true) + } + } + + static func mergeMastodonUser( + for requestMastodonUser: MastodonUser?, + old user: MastodonUser, + in domain: String, + entity: Mastodon.Entity.Account, + networkDate: Date + ) { + guard networkDate > user.updatedAt else { return } + let property = MastodonUser.Property(entity: entity, domain: domain, networkDate: networkDate) + + // only fulfill API supported fields + user.update(acct: property.acct) + user.update(username: property.username) + user.update(displayName: property.displayName) + user.update(avatar: property.avatar) + user.update(avatarStatic: property.avatarStatic) + + user.didUpdate(at: networkDate) + } + +} diff --git a/Mastodon/Service/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift similarity index 100% rename from Mastodon/Service/Persist/APIService+Persist+Timeline.swift rename to Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift new file mode 100644 index 000000000..16969b8aa --- /dev/null +++ b/Mastodon/Service/AuthenticationService.swift @@ -0,0 +1,148 @@ +// +// AuthenticationService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +class AuthenticationService: NSObject { + + var disposeBag = Set() + // input + weak var apiService: APIService? + let managedObjectContext: NSManagedObjectContext // read-only + let backgroundManagedObjectContext: NSManagedObjectContext + let mastodonAuthenticationFetchedResultsController: NSFetchedResultsController + + // output + let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([]) + let activeMastodonAuthentication = CurrentValueSubject(nil) + let activeMastodonAuthenticationBox = CurrentValueSubject(nil) + + init( + managedObjectContext: NSManagedObjectContext, + backgroundManagedObjectContext: NSManagedObjectContext, + apiService: APIService + ) { + self.managedObjectContext = managedObjectContext + self.backgroundManagedObjectContext = backgroundManagedObjectContext + self.apiService = apiService + self.mastodonAuthenticationFetchedResultsController = { + let fetchRequest = MastodonAuthentication.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + return controller + }() + super.init() + + mastodonAuthenticationFetchedResultsController.delegate = self + + // TODO: verify credentials for active authentication + + // bind data + mastodonAuthentications + .map { $0.sorted(by: { $0.activedAt > $1.activedAt }).first } + .assign(to: \.value, on: activeMastodonAuthentication) + .store(in: &disposeBag) + + activeMastodonAuthentication + .map { authentication -> AuthenticationService.MastodonAuthenticationBox? in + guard let authentication = authentication else { return nil } + return AuthenticationService.MastodonAuthenticationBox( + userID: authentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) + ) + } + .assign(to: \.value, on: activeMastodonAuthenticationBox) + .store(in: &disposeBag) + + do { + try mastodonAuthenticationFetchedResultsController.performFetch() + mastodonAuthentications.value = mastodonAuthenticationFetchedResultsController.fetchedObjects ?? [] + } catch { + assertionFailure(error.localizedDescription) + } + } + +} + +extension AuthenticationService { + struct MastodonAuthenticationBox { + let userID: MastodonUser.ID + let appAuthorization: Mastodon.API.OAuth.Authorization + let userAuthorization: Mastodon.API.OAuth.Authorization + } +} + +extension AuthenticationService { + + func activeMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { + var isActived = false + + return backgroundManagedObjectContext.performChanges { + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) + request.fetchLimit = 1 + guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else { + return + } + mastodonAutentication.update(activedAt: Date()) + isActived = true + } + .map { result in + return result.map { isActived } + } + .eraseToAnyPublisher() + } + + func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { + var isSignOut = false + + return backgroundManagedObjectContext.performChanges { + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) + request.fetchLimit = 1 + guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else { + return + } + self.backgroundManagedObjectContext.delete(mastodonAutentication) + isSignOut = true + } + .map { result in + return result.map { isSignOut } + } + .eraseToAnyPublisher() + } + +} + + +// MARK: - NSFetchedResultsControllerDelegate +extension AuthenticationService: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + if controller === mastodonAuthenticationFetchedResultsController { + mastodonAuthentications.value = mastodonAuthenticationFetchedResultsController.fetchedObjects ?? [] + } + } + +} + diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 19cb4757d..08918496b 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -22,6 +22,7 @@ class AppContext: ObservableObject { let backgroundManagedObjectContext: NSManagedObjectContext let apiService: APIService + let authenticationService: AuthenticationService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -39,6 +40,11 @@ class AppContext: ObservableObject { let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext) apiService = _apiService + authenticationService = AuthenticationService( + managedObjectContext: _managedObjectContext, + backgroundManagedObjectContext: _backgroundManagedObjectContext, + apiService: _apiService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 6aeb17d17..82bc645b7 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -6,6 +6,7 @@ // import UIKit +import CoreDataStack class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -25,12 +26,25 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setup() - #if DEBUG - DispatchQueue.main.async { - let authenticationViewModel = AuthenticationViewModel(context: appContext, coordinator: sceneCoordinator) - sceneCoordinator.present(scene: .authentication(viewModel: authenticationViewModel), from: nil, transition: .modal(animated: false, completion: nil)) + do { + let request = MastodonAuthentication.sortedFetchRequest + if try appContext.managedObjectContext.fetch(request).isEmpty { + DispatchQueue.main.async { + let authenticationViewModel = AuthenticationViewModel( + context: appContext, + coordinator: sceneCoordinator, + isAuthenticationExist: false + ) + sceneCoordinator.present( + scene: .authentication(viewModel: authenticationViewModel), + from: nil, + transition: .modal(animated: false, completion: nil) + ) + } + } + } catch { + assertionFailure(error.localizedDescription) } - #endif window.makeKeyAndVisible() } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift index edf078109..61e4c98aa 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift @@ -16,3 +16,22 @@ extension Mastodon.API.Error { } } } + +// MARK: - LocalizedError +extension Mastodon.API.Error.MastodonError: LocalizedError { + + public var errorDescription: String? { + switch self { + case .generic(let error): + return error.error + } + } + + public var failureReason: String? { + switch self { + case .generic(let error): + return error.errorDescription + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index 4929b0bb9..81fe9cd00 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -11,7 +11,7 @@ import Combine extension Mastodon.API.Account { static func verifyCredentialsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain) + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials") } public static func verifyCredentials( diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift index 5d0884377..d6e174574 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift @@ -13,7 +13,7 @@ extension Mastodon.Entity { /// - Since: 1.5.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/1/28 + /// 2021/2/3 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/source/) public struct Source: Codable { @@ -25,7 +25,7 @@ extension Mastodon.Entity { public let privacy: Privacy? public let sensitive: Bool? public let language: String? // (ISO 639-1 language two-letter code) - public let followRequestsCount: String + public let followRequestsCount: Int? enum CodingKeys: String, CodingKey { case note