feat: implement authentication scene

This commit is contained in:
CMK 2021-02-03 16:01:08 +08:00
parent 36c1807182
commit 2c6a0e383a
28 changed files with 1075 additions and 141 deletions

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D64" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Emoji" representedClassName=".Emoji" syncable="YES"> <entity name="Emoji" representedClassName=".Emoji" syncable="YES">
<attribute name="category" optional="YES" attributeType="String"/> <attribute name="category" optional="YES" attributeType="String"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/> <attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
@ -25,6 +25,20 @@
<attribute name="userIdentifier" attributeType="String"/> <attribute name="userIdentifier" attributeType="String"/>
<relationship name="toots" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="homeTimelineIndexes" inverseEntity="Toot"/> <relationship name="toots" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="homeTimelineIndexes" inverseEntity="Toot"/>
</entity> </entity>
<entity name="MastodonAuthentication" representedClassName=".MastodonAuthentication" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="appAccessToken" attributeType="String"/>
<attribute name="clientID" attributeType="String"/>
<attribute name="clientSecret" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userAccessToken" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="user" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mastodonAuthentication" inverseEntity="MastodonUser"/>
</entity>
<entity name="MastodonUser" representedClassName=".MastodonUser" syncable="YES"> <entity name="MastodonUser" representedClassName=".MastodonUser" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="String"/> <attribute name="avatar" attributeType="String"/>
@ -38,6 +52,7 @@
<attribute name="username" attributeType="String"/> <attribute name="username" attributeType="String"/>
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="bookmarked" inverseEntity="Toot"/> <relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="bookmarked" inverseEntity="Toot"/>
<relationship name="favourite" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="favouritedBy" inverseEntity="Toot"/> <relationship name="favourite" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="favouritedBy" inverseEntity="Toot"/>
<relationship name="mastodonAuthentication" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="user" inverseEntity="MastodonAuthentication"/>
<relationship name="muted" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="mutedBy" inverseEntity="Toot"/> <relationship name="muted" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="mutedBy" inverseEntity="Toot"/>
<relationship name="pinnedToot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="pinnedBy" inverseEntity="Toot"/> <relationship name="pinnedToot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="pinnedBy" inverseEntity="Toot"/>
<relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="rebloggedBy" inverseEntity="Toot"/> <relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="rebloggedBy" inverseEntity="Toot"/>
@ -97,9 +112,10 @@
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/> <element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
<element name="History" positionX="27" positionY="126" width="128" height="119"/> <element name="History" positionX="27" positionY="126" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="104"/> <element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="104"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="269"/> <element name="MastodonUser" positionX="0" positionY="0" width="128" height="284"/>
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/> <element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/> <element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
<element name="Toot" positionX="0" positionY="0" width="128" height="494"/> <element name="Toot" positionX="0" positionY="0" width="128" height="494"/>
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
</elements> </elements>
</model> </model>

View File

@ -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)
])
}
}

View File

@ -8,12 +8,14 @@
import CoreData import CoreData
import Foundation import Foundation
public final class MastodonUser: NSManagedObject { final public class MastodonUser: NSManagedObject {
public typealias ID = String public typealias ID = String
@NSManaged public private(set) var identifier: ID @NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var domain: String @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 acct: String
@NSManaged public private(set) var username: String @NSManaged public private(set) var username: String
@NSManaged public private(set) var displayName: String @NSManaged public private(set) var displayName: String
@ -25,6 +27,7 @@ public final class MastodonUser: NSManagedObject {
// one-to-one relationship // one-to-one relationship
@NSManaged public private(set) var pinnedToot: Toot? @NSManaged public private(set) var pinnedToot: Toot?
@NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication?
// one-to-many relationship // one-to-many relationship
@NSManaged public private(set) var toots: Set<Toot>? @NSManaged public private(set) var toots: Set<Toot>?
@ -36,11 +39,13 @@ public final class MastodonUser: NSManagedObject {
@NSManaged public private(set) var bookmarked: Set<Toot>? @NSManaged public private(set) var bookmarked: Set<Toot>?
@NSManaged public private(set) var retweets: Set<Toot>? @NSManaged public private(set) var retweets: Set<Toot>?
} }
public extension MastodonUser { extension MastodonUser {
@discardableResult @discardableResult
static func insert( public static func insert(
into context: NSManagedObjectContext, into context: NSManagedObjectContext,
property: Property property: Property
) -> MastodonUser { ) -> MastodonUser {
@ -61,6 +66,38 @@ public extension MastodonUser {
return user 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 { public extension MastodonUser {
@ -108,3 +145,44 @@ extension MastodonUser: Managed {
return [NSSortDescriptor(keyPath: \MastodonUser.createdAt, ascending: false)] 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)
])
}
}

View File

@ -54,6 +54,13 @@
DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; }; DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; };
DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; };
DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.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 */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; };
DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; 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, ); }; }; 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; }; 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 = "<group>"; }; DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = "<group>"; };
DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = "<group>"; };
DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = "<group>"; };
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonUser.swift"; sourceTree = "<group>"; };
DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUser.swift; sourceTree = "<group>"; };
DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthentication.swift; sourceTree = "<group>"; };
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = "<group>"; };
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; };
DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = "<group>"; };
DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -322,13 +336,8 @@
2D61335525C1886800CAE157 /* Service */ = { 2D61335525C1886800CAE157 /* Service */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2D61335D25C1894B00CAE157 /* APIService.swift */, DB45FB0425CA87B4005A8AC7 /* APIService */,
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
DB98336A25C9420100AD9700 /* APIService+App.swift */,
DB98337025C9443200AD9700 /* APIService+Authentication.swift */,
DB98339B25C96DE600AD9700 /* APIService+Account.swift */,
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */,
2D61335625C1887F00CAE157 /* Persist */,
); );
path = Service; path = Service;
sourceTree = "<group>"; sourceTree = "<group>";
@ -522,6 +531,30 @@
path = MastodonUITests; path = MastodonUITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
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 = "<group>";
};
DB45FB0925CA87BC005A8AC7 /* CoreData */ = {
isa = PBXGroup;
children = (
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
);
path = CoreData;
sourceTree = "<group>";
};
DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { DB89B9EF25C10FD0008580ED /* CoreDataStack */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -566,6 +599,7 @@
2D927F0725C7E9A8004F19B8 /* Tag.swift */, 2D927F0725C7E9A8004F19B8 /* Tag.swift */,
2D927F0D25C7E9C9004F19B8 /* History.swift */, 2D927F0D25C7E9C9004F19B8 /* History.swift */,
2D927F1325C7EDD9004F19B8 /* Emoji.swift */, 2D927F1325C7EDD9004F19B8 /* Emoji.swift */,
DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */,
); );
path = Entity; path = Entity;
sourceTree = "<group>"; sourceTree = "<group>";
@ -621,13 +655,16 @@
DB8AF56225C138BC002E6C99 /* Extension */ = { DB8AF56225C138BC002E6C99 /* Extension */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */,
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */,
2D46976325C2A71500CF4AA9 /* UIIamge.swift */, 2D46976325C2A71500CF4AA9 /* UIIamge.swift */,
2DF123A625C3B0210020F248 /* ActiveLabel.swift */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */,
2D42FF6A25C817D2004A627A /* MastodonContent.swift */, 2D42FF6A25C817D2004A627A /* MastodonContent.swift */,
DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */,
2D42FF8E25C8228A004A627A /* UIButton.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */,
DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */,
); );
path = Extension; path = Extension;
sourceTree = "<group>"; sourceTree = "<group>";
@ -998,10 +1035,12 @@
2D7631B325C159F700929FB9 /* Item.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */,
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */, 2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */,
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */,
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
@ -1014,14 +1053,17 @@
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */, DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */, 2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */,
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */,
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
@ -1034,6 +1076,7 @@
DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */, DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -1069,6 +1112,7 @@
DB89BA4425C1165F008580ED /* Managed.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */,
DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */,
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */,
DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */,
2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */,
DB89BA1D25C1107F008580ED /* URL.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */,
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */,

View File

@ -39,6 +39,8 @@ extension SceneCoordinator {
enum Scene { enum Scene {
case authentication(viewModel: AuthenticationViewModel) case authentication(viewModel: AuthenticationViewModel)
case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel) case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel)
case alertController(alertController: UIAlertController)
} }
} }
@ -118,6 +120,15 @@ private extension SceneCoordinator {
let _viewController = MastodonPinBasedAuthenticationViewController() let _viewController = MastodonPinBasedAuthenticationViewController()
_viewController.viewModel = viewModel _viewController.viewModel = viewModel
viewController = _viewController 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) setupDependency(for: viewController as? NeedsDependency)

View File

@ -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
)
}
}

View File

@ -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
)
}
}

View File

@ -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
}
}

View File

@ -18,7 +18,6 @@ final class AuthenticationViewController: UIViewController, NeedsDependency {
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var viewModel: AuthenticationViewModel! var viewModel: AuthenticationViewModel!
var mastodonPinBasedAuthenticationViewController: UIViewController?
let domainTextField: UITextField = { let domainTextField: UITextField = {
let textField = 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(_:))) private(set) lazy var signInBarButtonItem = UIBarButtonItem(title: "Sign In", style: .plain, target: self, action: #selector(AuthenticationViewController.signInBarButtonItemPressed(_:)))
let activityIndicatorBarButtonItem = UIBarButtonItem.activityIndicatorBarButtonItem
} }
extension AuthenticationViewController { extension AuthenticationViewController {
@ -59,10 +58,59 @@ extension AuthenticationViewController {
.assign(to: \.value, on: viewModel.input) .assign(to: \.value, on: viewModel.input)
.store(in: &disposeBag) .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 viewModel.isSignInButtonEnabled
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: signInBarButtonItem) .assign(to: \.isEnabled, on: signInBarButtonItem)
.store(in: &disposeBag) .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) { override func viewWillAppear(_ animated: Bool) {
@ -81,7 +129,51 @@ extension AuthenticationViewController {
// TODO: alert error // TODO: alert error
return 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)
} }
} }

View File

@ -7,6 +7,8 @@
import os.log import os.log
import UIKit import UIKit
import CoreData
import CoreDataStack
import Combine import Combine
import MastodonSDK import MastodonSDK
@ -17,21 +19,24 @@ final class AuthenticationViewModel {
// input // input
let context: AppContext let context: AppContext
let coordinator: SceneCoordinator let coordinator: SceneCoordinator
let isAuthenticationExist: Bool
let input = CurrentValueSubject<String, Never>("") let input = CurrentValueSubject<String, Never>("")
let signInAction = PassthroughSubject<String, Never>()
// output // output
let viewHierarchyShouldReset: Bool
let domain = CurrentValueSubject<String?, Never>(nil) let domain = CurrentValueSubject<String?, Never>(nil)
let isSignInButtonEnabled = CurrentValueSubject<Bool, Never>(false) let isSignInButtonEnabled = CurrentValueSubject<Bool, Never>(false)
let isAuthenticating = CurrentValueSubject<Bool, Never>(false) let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
let authenticated = PassthroughSubject<Void, Never>() let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
let error = CurrentValueSubject<Error?, Never>(nil) let error = CurrentValueSubject<Error?, Never>(nil)
private var mastodonPinBasedAuthenticationViewController: UIViewController? var mastodonPinBasedAuthenticationViewController: UIViewController?
init(context: AppContext, coordinator: SceneCoordinator) { init(context: AppContext, coordinator: SceneCoordinator, isAuthenticationExist: Bool) {
self.context = context self.context = context
self.coordinator = coordinator self.coordinator = coordinator
self.isAuthenticationExist = isAuthenticationExist
self.viewHierarchyShouldReset = isAuthenticationExist
input input
.map { input in .map { input in
@ -44,7 +49,10 @@ final class AuthenticationViewModel {
return nil return nil
} }
let components = host.components(separatedBy: ".") 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 return host
} }
@ -55,60 +63,6 @@ final class AuthenticationViewModel {
.map { $0 != nil } .map { $0 != nil }
.assign(to: \.value, on: isSignInButtonEnabled) .assign(to: \.value, on: isSignInButtonEnabled)
.store(in: &disposeBag) .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( return AuthenticationViewModel.verifyAndSaveAuthentication(
context: self.context, context: self.context,
info: info, info: info,
token: token userToken: token
) )
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
@ -165,7 +119,9 @@ extension AuthenticationViewModel {
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
guard let self = self else { return } guard let self = self else { return }
let account = response.value 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) .store(in: &self.disposeBag)
} }
@ -173,14 +129,52 @@ extension AuthenticationViewModel {
static func verifyAndSaveAuthentication( static func verifyAndSaveAuthentication(
context: AppContext, context: AppContext,
info: AuthenticateInfo, info: AuthenticateInfo,
token: Mastodon.Entity.Token userToken: Mastodon.Entity.Token
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> { ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, 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( return context.apiService.accountVerifyCredentials(
domain: info.domain, domain: info.domain,
authorization: authorization authorization: authorization
) )
// TODO: add persist logic .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 {
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()
} }
} }

View File

@ -89,6 +89,26 @@ extension MainTabBarController {
tabBarAppearance.configureWithDefaultBackground() tabBarAppearance.configureWithDefaultBackground()
tabBar.standardAppearance = tabBarAppearance 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 #if DEBUG
// selectedIndex = 1 // selectedIndex = 1
#endif #endif

View File

@ -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)
}
}
}

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
return Mastodon.API.Account.verifyCredentials(
session: session,
domain: domain,
authorization: authorization
)
}
}

View File

@ -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
}
}
}

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
return Mastodon.API.Account.verifyCredentials(
session: session,
domain: domain,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, 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()
}
}

View File

@ -25,6 +25,8 @@ final class APIService {
// input // input
let backgroundManagedObjectContext: NSManagedObjectContext let backgroundManagedObjectContext: NSManagedObjectContext
// output
let error = PassthroughSubject<APIError, Never>()
init(backgroundManagedObjectContext: NSManagedObjectContext) { init(backgroundManagedObjectContext: NSManagedObjectContext) {
self.backgroundManagedObjectContext = backgroundManagedObjectContext self.backgroundManagedObjectContext = backgroundManagedObjectContext

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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<AnyCancellable>()
// input
weak var apiService: APIService?
let managedObjectContext: NSManagedObjectContext // read-only
let backgroundManagedObjectContext: NSManagedObjectContext
let mastodonAuthenticationFetchedResultsController: NSFetchedResultsController<MastodonAuthentication>
// output
let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([])
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(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<Result<Bool, Error>, 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<Result<Bool, Error>, 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<NSFetchRequestResult>) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
if controller === mastodonAuthenticationFetchedResultsController {
mastodonAuthentications.value = mastodonAuthenticationFetchedResultsController.fetchedObjects ?? []
}
}
}

View File

@ -22,6 +22,7 @@ class AppContext: ObservableObject {
let backgroundManagedObjectContext: NSManagedObjectContext let backgroundManagedObjectContext: NSManagedObjectContext
let apiService: APIService let apiService: APIService
let authenticationService: AuthenticationService
let documentStore: DocumentStore let documentStore: DocumentStore
private var documentStoreSubscription: AnyCancellable! private var documentStoreSubscription: AnyCancellable!
@ -39,6 +40,11 @@ class AppContext: ObservableObject {
let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext) let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext)
apiService = _apiService apiService = _apiService
authenticationService = AuthenticationService(
managedObjectContext: _managedObjectContext,
backgroundManagedObjectContext: _backgroundManagedObjectContext,
apiService: _apiService
)
documentStore = DocumentStore() documentStore = DocumentStore()
documentStoreSubscription = documentStore.objectWillChange documentStoreSubscription = documentStore.objectWillChange

View File

@ -6,6 +6,7 @@
// //
import UIKit import UIKit
import CoreDataStack
class SceneDelegate: UIResponder, UIWindowSceneDelegate { class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@ -25,12 +26,25 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
sceneCoordinator.setup() sceneCoordinator.setup()
#if DEBUG do {
let request = MastodonAuthentication.sortedFetchRequest
if try appContext.managedObjectContext.fetch(request).isEmpty {
DispatchQueue.main.async { DispatchQueue.main.async {
let authenticationViewModel = AuthenticationViewModel(context: appContext, coordinator: sceneCoordinator) let authenticationViewModel = AuthenticationViewModel(
sceneCoordinator.present(scene: .authentication(viewModel: authenticationViewModel), from: nil, transition: .modal(animated: false, completion: nil)) 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() window.makeKeyAndVisible()
} }

View File

@ -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
}
}
}

View File

@ -11,7 +11,7 @@ import Combine
extension Mastodon.API.Account { extension Mastodon.API.Account {
static func verifyCredentialsEndpointURL(domain: String) -> URL { 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( public static func verifyCredentials(

View File

@ -13,7 +13,7 @@ extension Mastodon.Entity {
/// - Since: 1.5.0 /// - Since: 1.5.0
/// - Version: 3.3.0 /// - Version: 3.3.0
/// # Last Update /// # Last Update
/// 2021/1/28 /// 2021/2/3
/// # Reference /// # Reference
/// [Document](https://docs.joinmastodon.org/entities/source/) /// [Document](https://docs.joinmastodon.org/entities/source/)
public struct Source: Codable { public struct Source: Codable {
@ -25,7 +25,7 @@ extension Mastodon.Entity {
public let privacy: Privacy? public let privacy: Privacy?
public let sensitive: Bool? public let sensitive: Bool?
public let language: String? // (ISO 639-1 language two-letter code) public let language: String? // (ISO 639-1 language two-letter code)
public let followRequestsCount: String public let followRequestsCount: Int?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case note case note