diff --git a/.github/scripts/setup.sh b/.github/scripts/setup.sh index e1411fb50..0c2612d51 100755 --- a/.github/scripts/setup.sh +++ b/.github/scripts/setup.sh @@ -1,4 +1,9 @@ #!/bin/bash sudo gem install cocoapods-keys -pod install \ No newline at end of file + +# stub keys. DO NOT use in production +pod keys set notification_endpoint "" +pod keys set notification_endpoint_debug "" + +pod install diff --git a/AppShared/AppName.swift b/AppShared/AppName.swift new file mode 100644 index 000000000..9dbca78d8 --- /dev/null +++ b/AppShared/AppName.swift @@ -0,0 +1,12 @@ +// +// AppName.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +import Foundation + +public enum AppName { + public static let groupID = "group.org.joinmastodon.mastodon-temp" +} diff --git a/AppShared/AppSecret.swift b/AppShared/AppSecret.swift new file mode 100644 index 000000000..e2305ef1a --- /dev/null +++ b/AppShared/AppSecret.swift @@ -0,0 +1,103 @@ +// +// AppSecret.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + + +import Foundation +import CryptoKit +import KeychainAccess +import Keys + +public final class AppSecret { + + public static let keychain = Keychain(service: "org.joinmastodon.Mastodon.keychain", accessGroup: AppName.groupID) + + static let notificationPrivateKeyName = "notification-private-key-base64" + static let notificationAuthName = "notification-auth-base64" + + public let notificationEndpoint: String + + public var notificationPrivateKey: P256.KeyAgreement.PrivateKey { + AppSecret.createOrFetchNotificationPrivateKey() + } + public var notificationPublicKey: P256.KeyAgreement.PublicKey { + notificationPrivateKey.publicKey + } + public var notificationAuth: Data { + AppSecret.createOrFetchNotificationAuth() + } + + public static let `default`: AppSecret = { + return AppSecret() + }() + + init() { + let keys = MastodonKeys() + + #if DEBUG + self.notificationEndpoint = keys.notification_endpoint_debug + #else + self.notificationEndpoint = keys.notification_endpoint + #endif + } + + public func register() { + _ = AppSecret.createOrFetchNotificationPrivateKey() + _ = AppSecret.createOrFetchNotificationAuth() + } + +} + +extension AppSecret { + + private static func createOrFetchNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey { + if let encoded = AppSecret.keychain[AppSecret.notificationPrivateKeyName], + let data = Data(base64Encoded: encoded) { + do { + let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: data) + return privateKey + } catch { + assertionFailure() + return AppSecret.resetNotificationPrivateKey() + } + } else { + return AppSecret.resetNotificationPrivateKey() + } + } + + private static func resetNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey { + let privateKey = P256.KeyAgreement.PrivateKey() + keychain[AppSecret.notificationPrivateKeyName] = privateKey.rawRepresentation.base64EncodedString() + return privateKey + } + +} + +extension AppSecret { + + private static func createOrFetchNotificationAuth() -> Data { + if let encoded = keychain[AppSecret.notificationAuthName], + let data = Data(base64Encoded: encoded) { + return data + } else { + return AppSecret.resetNotificationAuth() + } + } + + private static func resetNotificationAuth() -> Data { + let auth = AppSecret.createRandomAuthBytes() + keychain[AppSecret.notificationAuthName] = auth.base64EncodedString() + return auth + } + + private static func createRandomAuthBytes() -> Data { + let byteCount = 16 + var bytes = Data(count: byteCount) + _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } + return bytes + } + +} diff --git a/AppShared/AppShared.h b/AppShared/AppShared.h new file mode 100644 index 000000000..3258d4fcb --- /dev/null +++ b/AppShared/AppShared.h @@ -0,0 +1,18 @@ +// +// AppShared.h +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +#import + +//! Project version number for AppShared. +FOUNDATION_EXPORT double AppSharedVersionNumber; + +//! Project version string for AppShared. +FOUNDATION_EXPORT const unsigned char AppSharedVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/AppShared/Info.plist b/AppShared/Info.plist new file mode 100644 index 000000000..9bcb24442 --- /dev/null +++ b/AppShared/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/AppShared/UserDefaults.swift b/AppShared/UserDefaults.swift new file mode 100644 index 000000000..9cecdcf60 --- /dev/null +++ b/AppShared/UserDefaults.swift @@ -0,0 +1,12 @@ +// +// UserDefaults.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +import UIKit + +extension UserDefaults { + public static let shared = UserDefaults(suiteName: AppName.groupID)! +} diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 0d0170282..69c30e990 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -172,14 +172,12 @@ - - - - - - - - + + + + + + @@ -221,24 +219,27 @@ - + + + - - - - + + + + - - - - - + + + + + + - + @@ -263,10 +264,10 @@ - + - - + + - + \ No newline at end of file diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 1d13ee5ee..64bf9c857 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -8,6 +8,7 @@ import os import Foundation import CoreData +import AppShared public final class CoreDataStack { @@ -18,7 +19,7 @@ public final class CoreDataStack { } public convenience init(databaseName: String = "shared") { - let storeURL = URL.storeURL(for: "group.org.joinmastodon.mastodon-temp", databaseName: databaseName) + let storeURL = URL.storeURL(for: AppName.groupID, databaseName: databaseName) let storeDescription = NSPersistentStoreDescription(url: storeURL) self.init(persistentStoreDescriptions: [storeDescription]) } diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift index 671f9bab3..6fac8c351 100644 --- a/CoreDataStack/Entity/Setting.swift +++ b/CoreDataStack/Entity/Setting.swift @@ -9,66 +9,61 @@ import CoreData import Foundation public final class Setting: NSManagedObject { - @NSManaged public var appearance: String? - @NSManaged public var triggerBy: String? - @NSManaged public var domain: String? - @NSManaged public var userID: String? + + @NSManaged public var appearanceRaw: String + @NSManaged public var domain: String + @NSManaged public var userID: String @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date - // relationships - @NSManaged public var subscription: Set? + // one-to-many relationships + @NSManaged public var subscriptions: Set? } -public extension Setting { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt)) - } +extension Setting { - func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Setting.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Setting.updatedAt)) } @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property ) -> Setting { let setting: Setting = context.insertObject() - setting.appearance = property.appearance - setting.triggerBy = property.triggerBy + setting.appearanceRaw = property.appearanceRaw setting.domain = property.domain setting.userID = property.userID return setting } - func update(appearance: String?) { - guard appearance != self.appearance else { return } - self.appearance = appearance + public func update(appearanceRaw: String) { + guard appearanceRaw != self.appearanceRaw else { return } + self.appearanceRaw = appearanceRaw didUpdate(at: Date()) } - func update(triggerBy: String?) { - guard triggerBy != self.triggerBy else { return } - self.triggerBy = triggerBy - didUpdate(at: Date()) + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate } + } -public extension Setting { - struct Property { - public let appearance: String - public let triggerBy: String +extension Setting { + public struct Property { public let domain: String public let userID: String + public let appearanceRaw: String - public init(appearance: String, triggerBy: String, domain: String, userID: String) { - self.appearance = appearance - self.triggerBy = triggerBy + public init(domain: String, userID: String, appearanceRaw: String) { self.domain = domain self.userID = userID + self.appearanceRaw = appearanceRaw } } } diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift index 8ced945d9..e1824be1c 100644 --- a/CoreDataStack/Entity/Subscription.swift +++ b/CoreDataStack/Entity/Subscription.swift @@ -10,30 +10,35 @@ import Foundation import CoreData public final class Subscription: NSManagedObject { - @NSManaged public var id: String - @NSManaged public var endpoint: String - @NSManaged public var serverKey: String - /// four types: - /// - anyone - /// - a follower - /// - anyone I follow - /// - no one - @NSManaged public var type: String + @NSManaged public var id: String? + @NSManaged public var endpoint: String? + @NSManaged public var policyRaw: String + @NSManaged public var serverKey: String? + @NSManaged public var userToken: String? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var activedAt: Date + + // MARK: one-to-one relationships + @NSManaged public var alert: SubscriptionAlerts - // MARK: - relationships - @NSManaged public var alert: SubscriptionAlerts? - // MARK: holder + // MARK: many-to-one relationships @NSManaged public var setting: Setting? } public extension Subscription { override func awakeFromInsert() { super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt)) + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Subscription.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.updatedAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.activedAt)) + } + + func update(activedAt: Date) { + self.activedAt = activedAt } func didUpdate(at networkDate: Date) { @@ -43,45 +48,22 @@ public extension Subscription { @discardableResult static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + setting: Setting ) -> Subscription { - let setting: Subscription = context.insertObject() - setting.id = property.id - setting.endpoint = property.endpoint - setting.serverKey = property.serverKey - setting.type = property.type - - return setting + let subscription: Subscription = context.insertObject() + subscription.policyRaw = property.policyRaw + subscription.setting = setting + return subscription } } public extension Subscription { struct Property { - public let endpoint: String - public let id: String - public let serverKey: String - public let type: String + public let policyRaw: String - public init(endpoint: String, id: String, serverKey: String, type: String) { - self.endpoint = endpoint - self.id = id - self.serverKey = serverKey - self.type = type - } - } - - func updateIfNeed(property: Property) { - if self.endpoint != property.endpoint { - self.endpoint = property.endpoint - } - if self.id != property.id { - self.id = property.id - } - if self.serverKey != property.serverKey { - self.serverKey = property.serverKey - } - if self.type != property.type { - self.type = property.type + public init(policyRaw: String) { + self.policyRaw = policyRaw } } } @@ -94,8 +76,12 @@ extension Subscription: Managed { extension Subscription { - public static func predicate(type: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type) + public static func predicate(policyRaw: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw) + } + + public static func predicate(userToken: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.userToken), userToken) } } diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift index f5abf4955..613d1caf7 100644 --- a/CoreDataStack/Entity/SubscriptionAlerts.swift +++ b/CoreDataStack/Entity/SubscriptionAlerts.swift @@ -10,117 +10,165 @@ import Foundation import CoreData public final class SubscriptionAlerts: NSManagedObject { - @NSManaged public var follow: NSNumber? - @NSManaged public var favourite: NSNumber? - @NSManaged public var reblog: NSNumber? - @NSManaged public var mention: NSNumber? - @NSManaged public var poll: NSNumber? + @NSManaged public var favouriteRaw: NSNumber? + @NSManaged public var followRaw: NSNumber? + @NSManaged public var followRequestRaw: NSNumber? + @NSManaged public var mentionRaw: NSNumber? + @NSManaged public var pollRaw: NSNumber? + @NSManaged public var reblogRaw: NSNumber? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date - // MARK: - relationships - @NSManaged public var subscription: Subscription? + // MARK: one-to-one relationships + @NSManaged public var subscription: Subscription } -public extension SubscriptionAlerts { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt)) - } +extension SubscriptionAlerts { - func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.updatedAt)) } @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + subscription: Subscription ) -> SubscriptionAlerts { let alerts: SubscriptionAlerts = context.insertObject() - alerts.favourite = property.favourite - alerts.follow = property.follow - alerts.mention = property.mention - alerts.poll = property.poll - alerts.reblog = property.reblog + + alerts.favouriteRaw = property.favouriteRaw + alerts.followRaw = property.followRaw + alerts.followRequestRaw = property.followRequestRaw + alerts.mentionRaw = property.mentionRaw + alerts.pollRaw = property.pollRaw + alerts.reblogRaw = property.reblogRaw + + alerts.subscription = subscription + return alerts } - func update(favourite: NSNumber?) { + public func update(favourite: Bool?) { guard self.favourite != favourite else { return } self.favourite = favourite didUpdate(at: Date()) } - func update(follow: NSNumber?) { + public func update(follow: Bool?) { guard self.follow != follow else { return } self.follow = follow didUpdate(at: Date()) } - func update(mention: NSNumber?) { + public func update(followRequest: Bool?) { + guard self.followRequest != followRequest else { return } + self.followRequest = followRequest + + didUpdate(at: Date()) + } + + public func update(mention: Bool?) { guard self.mention != mention else { return } self.mention = mention didUpdate(at: Date()) } - func update(poll: NSNumber?) { + public func update(poll: Bool?) { guard self.poll != poll else { return } self.poll = poll didUpdate(at: Date()) } - func update(reblog: NSNumber?) { + public func update(reblog: Bool?) { guard self.reblog != reblog else { return } self.reblog = reblog didUpdate(at: Date()) } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } -public extension SubscriptionAlerts { - struct Property { - public let favourite: NSNumber? - public let follow: NSNumber? - public let mention: NSNumber? - public let poll: NSNumber? - public let reblog: NSNumber? +extension SubscriptionAlerts { + + private func boolean(from number: NSNumber?) -> Bool? { + return number.flatMap { $0.intValue == 1 } + } + + private func number(from boolean: Bool?) -> NSNumber? { + return boolean.flatMap { NSNumber(integerLiteral: $0 ? 1 : 0) } + } + + public var favourite: Bool? { + get { boolean(from: favouriteRaw) } + set { favouriteRaw = number(from: newValue) } + } + + public var follow: Bool? { + get { boolean(from: followRaw) } + set { followRaw = number(from: newValue) } + } + + public var followRequest: Bool? { + get { boolean(from: followRequestRaw) } + set { followRequestRaw = number(from: newValue) } + } + + public var mention: Bool? { + get { boolean(from: mentionRaw) } + set { mentionRaw = number(from: newValue) } + } + + public var poll: Bool? { + get { boolean(from: pollRaw) } + set { pollRaw = number(from: newValue) } + } + + public var reblog: Bool? { + get { boolean(from: reblogRaw) } + set { reblogRaw = number(from: newValue) } + } + +} - public init(favourite: NSNumber?, follow: NSNumber?, mention: NSNumber?, poll: NSNumber?, reblog: NSNumber?) { - self.favourite = favourite - self.follow = follow - self.mention = mention - self.poll = poll - self.reblog = reblog +extension SubscriptionAlerts { + public struct Property { + public let favouriteRaw: NSNumber? + public let followRaw: NSNumber? + public let followRequestRaw: NSNumber? + public let mentionRaw: NSNumber? + public let pollRaw: NSNumber? + public let reblogRaw: NSNumber? + + public init( + favourite: Bool?, + follow: Bool?, + followRequest: Bool?, + mention: Bool?, + poll: Bool?, + reblog: Bool? + ) { + self.favouriteRaw = favourite.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRaw = follow.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRequestRaw = followRequest.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.mentionRaw = mention.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.pollRaw = poll.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.reblogRaw = reblog.flatMap { NSNumber(value: $0 ? 1 : 0) } } } - func updateIfNeed(property: Property) { - if self.follow != property.follow { - self.follow = property.follow - } - - if self.favourite != property.favourite { - self.favourite = property.favourite - } - - if self.reblog != property.reblog { - self.reblog = property.reblog - } - - if self.mention != property.mention { - self.mention = property.mention - } - - if self.poll != property.poll { - self.poll = property.poll - } - } } extension SubscriptionAlerts: Managed { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 041a837c5..adbfa70ac 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -16,7 +16,7 @@ 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; }; 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; }; - 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; }; + 0F20223926146553000C64BF /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; @@ -148,7 +148,6 @@ 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; }; 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; }; - 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.swift */; }; 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; }; 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; }; 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; }; @@ -175,7 +174,10 @@ 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; + 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */; }; 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; + D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */; }; + DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; @@ -235,6 +237,7 @@ DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */; }; DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; + DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; @@ -254,6 +257,21 @@ DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; }; DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; + DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; + DB6804662636DC9000430867 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; + DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; + DB6804832637CD4C00430867 /* AppShared.h in Headers */ = {isa = PBXBuildFile; fileRef = DB6804812637CD4C00430867 /* AppShared.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DB6804922637CD8700430867 /* AppName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804912637CD8700430867 /* AppName.swift */; }; + DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804A62637CDCC00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804D02637CE4700430867 /* UserDefaults.swift */; }; + DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804FC2637CFEC00430867 /* AppSecret.swift */; }; + DB6805102637D0F800430867 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = DB68050F2637D0F800430867 /* KeychainAccess */; }; + DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6805272637D7DD00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -261,6 +279,20 @@ DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; }; + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; }; + DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; }; + DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; }; + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4826353FD6008423CD /* Subscription.swift */; }; + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */; }; + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */; }; + DB6D9F6326357848008423CD /* SettingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6226357848008423CD /* SettingService.swift */; }; + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6E2635807F008423CD /* Setting.swift */; }; + DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */; }; + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; }; + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; }; + DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; @@ -380,8 +412,15 @@ DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; }; DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; + DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; + DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; + DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; }; + DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DBF8AE862632992800C9C23C /* Base85 in Frameworks */ = {isa = PBXBuildFile; productRef = DBF8AE852632992800C9C23C /* Base85 */; }; + DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -399,6 +438,34 @@ remoteGlobalIDString = DB427DD125BAA00100D1B89D; remoteInfo = Mastodon; }; + DB6804842637CD4C00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6804A72637CDCC00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6804C92637CE3000430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6805282637D7DD00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; DB89B9F825C10FD0008580ED /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; @@ -420,21 +487,62 @@ remoteGlobalIDString = DB89B9ED25C10FD0008580ED; remoteInfo = CoreDataStack; }; + DBF8AE18263293E400C9C23C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DBF8AE12263293E400C9C23C; + remoteInfo = NotificationService; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + DB6804A92637CDCC00430867 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DB6804A62637CDCC00430867 /* AppShared.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + DB68052A2637D7DD00430867 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DB6805272637D7DD00430867 /* AppShared.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; DB89BA0825C10FD0008580ED /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( + DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */, DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE1B263293E400C9C23C /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -447,7 +555,7 @@ 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; - 0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = ""; }; + 0F20223826146553000C64BF /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = ""; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -572,6 +680,7 @@ 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = ""; }; 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = ""; }; + 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = ""; }; 5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = ""; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -579,7 +688,6 @@ 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = ""; }; 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = ""; }; - 5B90C45D262599800002E742 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; 5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; 5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; @@ -604,10 +712,15 @@ 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = ""; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; + 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.release.xcconfig"; sourceTree = ""; }; + B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.release.xcconfig"; sourceTree = ""; }; BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = ""; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = ""; }; DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewController.swift; sourceTree = ""; }; DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; @@ -673,6 +786,7 @@ DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+State.swift"; sourceTree = ""; }; DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = ""; }; + DB4924E126312AB200E9DB22 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; @@ -691,6 +805,14 @@ DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.swift"; sourceTree = ""; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; + DB68045A2636DC6A00430867 /* MastodonNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonNotification.swift; sourceTree = ""; }; + DB68047F2637CD4C00430867 /* AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DB6804812637CD4C00430867 /* AppShared.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppShared.h; sourceTree = ""; }; + DB6804822637CD4C00430867 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB6804912637CD8700430867 /* AppName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppName.swift; sourceTree = ""; }; + DB6804D02637CE4700430867 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + DB6804FC2637CFEC00430867 /* AppSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecret.swift; sourceTree = ""; }; + DB68053E2638011000430867 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -698,6 +820,19 @@ DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB6D1B23263684C600ACB481 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = ""; }; + DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; + DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; + DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Setting.swift"; sourceTree = ""; }; + DB6D9F6226357848008423CD /* SettingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingService.swift; sourceTree = ""; }; + DB6D9F6E2635807F008423CD /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingFetchedResultController.swift; sourceTree = ""; }; + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; + DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = ""; }; + DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -818,8 +953,13 @@ DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = ""; }; DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+StatusProvider.swift"; sourceTree = ""; }; DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; + DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; + DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DBF8AE15263293E400C9C23C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + DBF8AE17263293E400C9C23C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -835,12 +975,15 @@ 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, DBB525082611EAC0002F1F29 /* Tabman in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, + DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, + DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, + DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -861,10 +1004,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047C2637CD4C00430867 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6805102637D0F800430867 /* KeychainAccess in Frameworks */, + D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EB25C10FD0008580ED /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -876,6 +1029,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE10263293E400C9C23C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */, + DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */, + DBF8AE862632992800C9C23C /* Base85 in Frameworks */, + DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */, + 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -954,6 +1119,10 @@ BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */, EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */, 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */, + 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */, + B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */, + D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */, + B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -1091,6 +1260,8 @@ 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, + DB4924E126312AB200E9DB22 /* NotificationService.swift */, + DB6D9F6226357848008423CD /* SettingService.swift */, ); path = Service; sourceTree = ""; @@ -1159,6 +1330,7 @@ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, ); path = Section; @@ -1217,6 +1389,7 @@ DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, + DB6D9F8326358EEC008423CD /* SettingsItem.swift */, ); path = Item; sourceTree = ""; @@ -1276,9 +1449,12 @@ 3FE14AD363ED19AE7FF210A6 /* Frameworks */ = { isa = PBXGroup; children = ( + DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */, A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */, 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */, 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */, + 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */, + 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */, ); name = Frameworks; sourceTree = ""; @@ -1301,8 +1477,8 @@ isa = PBXGroup; children = ( 5B90C457262599800002E742 /* View */, + DB6D9F9626367249008423CD /* SettingsViewController.swift */, 5B90C456262599800002E742 /* SettingsViewModel.swift */, - 5B90C45D262599800002E742 /* SettingsViewController.swift */, ); path = Settings; sourceTree = ""; @@ -1366,6 +1542,9 @@ DB084B5625CBC56C00F898ED /* Status.swift */, DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */, + DB6D9F6E2635807F008423CD /* Setting.swift */, + DB6D9F4826353FD6008423CD /* Subscription.swift */, + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */, ); path = CoreDataStack; sourceTree = ""; @@ -1421,6 +1600,8 @@ DB427DF625BAA00100D1B89D /* MastodonUITests */, DB89B9EF25C10FD0008580ED /* CoreDataStack */, DB89B9FC25C10FD0008580ED /* CoreDataStackTests */, + DBF8AE14263293E400C9C23C /* NotificationService */, + DB6804802637CD4C00430867 /* AppShared */, DB427DD325BAA00100D1B89D /* Products */, 1EBA4F56E920856A3FC84ACB /* Pods */, 3FE14AD363ED19AE7FF210A6 /* Frameworks */, @@ -1436,6 +1617,8 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */, DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */, DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */, + DBF8AE13263293E400C9C23C /* NotificationService.appex */, + DB68047F2637CD4C00430867 /* AppShared.framework */, ); name = Products; sourceTree = ""; @@ -1525,6 +1708,7 @@ DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, + DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */, 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, ); path = CoreData; @@ -1544,6 +1728,8 @@ isa = PBXGroup; children = ( DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */, + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */, + DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */, ); path = Preference; sourceTree = ""; @@ -1561,6 +1747,18 @@ path = View; sourceTree = ""; }; + DB6804802637CD4C00430867 /* AppShared */ = { + isa = PBXGroup; + children = ( + DB6804812637CD4C00430867 /* AppShared.h */, + DB6804822637CD4C00430867 /* Info.plist */, + DB6804912637CD8700430867 /* AppName.swift */, + DB6804FC2637CFEC00430867 /* AppSecret.swift */, + DB6804D02637CE4700430867 /* UserDefaults.swift */, + ); + path = AppShared; + sourceTree = ""; + }; DB68A03825E900CC00CFDF14 /* Share */ = { isa = PBXGroup; children = ( @@ -1587,6 +1785,7 @@ 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */, + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */, ); path = MastodonSDK; sourceTree = ""; @@ -1792,9 +1991,10 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, - 0F20223826146553000C64BF /* Array+removeDuplicates.swift */, + 0F20223826146553000C64BF /* Array.swift */, DBCC3B2F261440A50045B23D /* UITabBarController.swift */, DBCC3B35261440BA0045B23D /* UINavigationController.swift */, + DB6D1B23263684C600ACB481 /* UserDefaults.swift */, ); path = Extension; sourceTree = ""; @@ -2002,6 +2202,7 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2028,9 +2229,29 @@ path = Favorite; sourceTree = ""; }; + DBF8AE14263293E400C9C23C /* NotificationService */ = { + isa = PBXGroup; + children = ( + DB68053E2638011000430867 /* NotificationService.entitlements */, + DBF8AE15263293E400C9C23C /* NotificationService.swift */, + DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */, + DB68045A2636DC6A00430867 /* MastodonNotification.swift */, + DBF8AE17263293E400C9C23C /* Info.plist */, + ); + path = NotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ + DB68047A2637CD4C00430867 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6804832637CD4C00430867 /* AppShared.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9E925C10FD0008580ED /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; @@ -2053,11 +2274,15 @@ DB427DD025BAA00100D1B89D /* Resources */, 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */, DB89BA0825C10FD0008580ED /* Embed Frameworks */, + DBF8AE1B263293E400C9C23C /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( DB89BA0225C10FD0008580ED /* PBXTargetDependency */, + DBF8AE19263293E400C9C23C /* PBXTargetDependency */, + DB6804852637CD4C00430867 /* PBXTargetDependency */, + DB6804CA2637CE3000430867 /* PBXTargetDependency */, ); name = Mastodon; packageProductDependencies = ( @@ -2116,6 +2341,28 @@ productReference = DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + DB68047E2637CD4C00430867 /* AppShared */ = { + isa = PBXNativeTarget; + buildConfigurationList = DB6804882637CD4C00430867 /* Build configuration list for PBXNativeTarget "AppShared" */; + buildPhases = ( + C6B7D3A8ACD77F6620D0E0AD /* [CP] Check Pods Manifest.lock */, + DB68047A2637CD4C00430867 /* Headers */, + DB68047B2637CD4C00430867 /* Sources */, + DB68047C2637CD4C00430867 /* Frameworks */, + DB68047D2637CD4C00430867 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AppShared; + packageProductDependencies = ( + DB68050F2637D0F800430867 /* KeychainAccess */, + ); + productName = AppShared; + productReference = DB68047F2637CD4C00430867 /* AppShared.framework */; + productType = "com.apple.product-type.framework"; + }; DB89B9ED25C10FD0008580ED /* CoreDataStack */ = { isa = PBXNativeTarget; buildConfigurationList = DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */; @@ -2124,10 +2371,12 @@ DB89B9EA25C10FD0008580ED /* Sources */, DB89B9EB25C10FD0008580ED /* Frameworks */, DB89B9EC25C10FD0008580ED /* Resources */, + DB68052A2637D7DD00430867 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + DB6805292637D7DD00430867 /* PBXTargetDependency */, ); name = CoreDataStack; productName = CoreDataStack; @@ -2153,6 +2402,31 @@ productReference = DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + DBF8AE12263293E400C9C23C /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = DBF8AE1E263293E400C9C23C /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + 0DC740704503CA6BED56F5C8 /* [CP] Check Pods Manifest.lock */, + DBF8AE0F263293E400C9C23C /* Sources */, + DBF8AE10263293E400C9C23C /* Frameworks */, + DBF8AE11263293E400C9C23C /* Resources */, + DB6804A92637CDCC00430867 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + DB6804A82637CDCC00430867 /* PBXTargetDependency */, + ); + name = NotificationService; + packageProductDependencies = ( + DBF8AE852632992800C9C23C /* Base85 */, + DB00CA962632DDB600A54956 /* CommonOSLog */, + DB6D9F41263527CE008423CD /* AlamofireImage */, + ); + productName = NotificationService; + productReference = DBF8AE13263293E400C9C23C /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -2174,6 +2448,10 @@ CreatedOnToolsVersion = 12.4; TestTargetID = DB427DD125BAA00100D1B89D; }; + DB68047E2637CD4C00430867 = { + CreatedOnToolsVersion = 12.4; + LastSwiftMigration = 1240; + }; DB89B9ED25C10FD0008580ED = { CreatedOnToolsVersion = 12.4; LastSwiftMigration = 1240; @@ -2182,6 +2460,9 @@ CreatedOnToolsVersion = 12.4; TestTargetID = DB427DD125BAA00100D1B89D; }; + DBF8AE12263293E400C9C23C = { + CreatedOnToolsVersion = 12.4; + }; }; }; buildConfigurationList = DB427DCD25BAA00100D1B89D /* Build configuration list for PBXProject "Mastodon" */; @@ -2204,6 +2485,8 @@ DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, + DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */, + DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2214,6 +2497,8 @@ DB427DF225BAA00100D1B89D /* MastodonUITests */, DB89B9ED25C10FD0008580ED /* CoreDataStack */, DB89B9F525C10FD0008580ED /* CoreDataStackTests */, + DBF8AE12263293E400C9C23C /* NotificationService */, + DB68047E2637CD4C00430867 /* AppShared */, ); }; /* End PBXProject section */ @@ -2247,6 +2532,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047D2637CD4C00430867 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EC25C10FD0008580ED /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2261,9 +2553,38 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE11263293E400C9C23C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0DC740704503CA6BED56F5C8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Mastodon-NotificationService-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2325,6 +2646,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + C6B7D3A8ACD77F6620D0E0AD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Mastodon-AppShared-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; DB3D100425BAA71500EAA174 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2498,6 +2841,7 @@ DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, + DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, @@ -2509,8 +2853,8 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, - 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, @@ -2544,6 +2888,7 @@ 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, + DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, @@ -2569,9 +2914,11 @@ DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */, + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, @@ -2592,6 +2939,7 @@ DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, @@ -2612,6 +2960,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, + DB6D9F6326357848008423CD /* SettingService.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, @@ -2625,8 +2974,10 @@ DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, + DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */, DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, @@ -2639,7 +2990,7 @@ DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, - 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */, + 0F20223926146553000C64BF /* Array.swift in Sources */, 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, @@ -2650,7 +3001,9 @@ 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */, 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, + DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */, @@ -2682,12 +3035,15 @@ DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, + DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, @@ -2696,6 +3052,7 @@ DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, @@ -2724,6 +3081,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047B2637CD4C00430867 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */, + DB6804922637CD8700430867 /* AppName.swift in Sources */, + DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EA25C10FD0008580ED /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2767,6 +3134,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE0F263293E400C9C23C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */, + DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */, + DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */, + DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */, + DB6804662636DC9000430867 /* String.swift in Sources */, + DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2780,6 +3160,26 @@ target = DB427DD125BAA00100D1B89D /* Mastodon */; targetProxy = DB427DF425BAA00100D1B89D /* PBXContainerItemProxy */; }; + DB6804852637CD4C00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804842637CD4C00430867 /* PBXContainerItemProxy */; + }; + DB6804A82637CDCC00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804A72637CDCC00430867 /* PBXContainerItemProxy */; + }; + DB6804CA2637CE3000430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804C92637CE3000430867 /* PBXContainerItemProxy */; + }; + DB6805292637D7DD00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6805282637D7DD00430867 /* PBXContainerItemProxy */; + }; DB89B9F925C10FD0008580ED /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; @@ -2795,6 +3195,11 @@ target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; targetProxy = DB89BA0125C10FD0008580ED /* PBXContainerItemProxy */; }; + DBF8AE19263293E400C9C23C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DBF8AE12263293E400C9C23C /* NotificationService */; + targetProxy = DBF8AE18263293E400C9C23C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -3084,6 +3489,65 @@ }; name = Release; }; + DB6804892637CD4C00430867 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AppShared/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + DB68048A2637CD4C00430867 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AppShared/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; DB89BA0625C10FD0008580ED /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3181,6 +3645,48 @@ }; name = Release; }; + DBF8AE1C263293E400C9C23C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DBF8AE1D263293E400C9C23C /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -3220,6 +3726,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DB6804882637CD4C00430867 /* Build configuration list for PBXNativeTarget "AppShared" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DB6804892637CD4C00430867 /* Debug */, + DB68048A2637CD4C00430867 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -3238,6 +3753,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DBF8AE1E263293E400C9C23C /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DBF8AE1C263293E400C9C23C /* Debug */, + DBF8AE1D263293E400C9C23C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -3297,6 +3821,14 @@ minimumVersion = 6.1.0; }; }; + DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.2.2; + }; + }; DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; @@ -3321,6 +3853,14 @@ kind = branch; }; }; + DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/Base85.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3348,6 +3888,11 @@ isa = XCSwiftPackageProductDependency; productName = MastodonSDK; }; + DB00CA962632DDB600A54956 /* CommonOSLog */ = { + isa = XCSwiftPackageProductDependency; + package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; + productName = CommonOSLog; + }; DB0140BC25C40D7500F9F3CF /* CommonOSLog */ = { isa = XCSwiftPackageProductDependency; package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; @@ -3363,6 +3908,16 @@ package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + DB68050F2637D0F800430867 /* KeychainAccess */ = { + isa = XCSwiftPackageProductDependency; + package = DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */; + productName = KeychainAccess; + }; + DB6D9F41263527CE008423CD /* AlamofireImage */ = { + isa = XCSwiftPackageProductDependency; + package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; + productName = AlamofireImage; + }; DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { isa = XCSwiftPackageProductDependency; package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; @@ -3378,6 +3933,11 @@ package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; productName = TwitterTextEditor; }; + DBF8AE852632992800C9C23C /* Base85 */ = { + isa = XCSwiftPackageProductDependency; + package = DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */; + productName = Base85; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 18c8840d8..083bcfbbe 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,25 +4,35 @@ SchemeUserState + AppShared.xcscheme_^#shared#^_ + + orderHint + 18 + CoreDataStack.xcscheme_^#shared#^_ orderHint - 13 + 17 Mastodon - RTL.xcscheme_^#shared#^_ orderHint - 9 + 2 Mastodon - Release.xcscheme_^#shared#^_ orderHint - 2 + 0 Mastodon.xcscheme_^#shared#^_ orderHint - 7 + 1 + + NotificationService.xcscheme_^#shared#^_ + + orderHint + 18 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 741947371..47136a2c7 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -37,6 +37,15 @@ "version": "3.1.0" } }, + { + "package": "Base85", + "repositoryURL": "https://github.com/MainasuK/Base85.git", + "state": { + "branch": null, + "revision": "626be96816618689627f806b5c875b5adb6346ef", + "version": "1.0.1" + } + }, { "package": "CommonOSLog", "repositoryURL": "https://github.com/MainasuK/CommonOSLog", @@ -46,6 +55,15 @@ "version": "0.1.1" } }, + { + "package": "KeychainAccess", + "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state": { + "branch": null, + "revision": "84e546727d66f1adc5439debad16270d0fdd04e7", + "version": "4.2.2" + } + }, { "package": "Kingfisher", "repositoryURL": "https://github.com/onevcat/Kingfisher.git", diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 95d50dca7..a71947e29 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -62,14 +62,19 @@ extension SceneCoordinator { case profile(viewModel: ProfileViewModel) case favorite(viewModel: FavoriteViewModel) + // setting + case settings(viewModel: SettingsViewModel) + + // report + case report(viewModel: ReportViewModel) + // suggestion account case suggestionAccount(viewModel: SuggestionAccountViewModel) + // misc case safari(url: URL) case alertController(alertController: UIAlertController) case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) - case settings(viewModel: SettingsViewModel) - case report(viewModel: ReportViewModel) #if DEBUG case publicTimeline #endif @@ -253,6 +258,10 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .settings(let viewModel): + let _viewController = SettingsViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .suggestionAccount(let viewModel): let _viewController = SuggestionAccountViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift new file mode 100644 index 000000000..52eafc6b6 --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift @@ -0,0 +1,64 @@ +// +// SettingFetchedResultController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class SettingFetchedResultController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + + // output + let settings = CurrentValueSubject<[Setting], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, additionalPredicate: NSPredicate?) { + self.fetchedResultsController = { + let fetchRequest = Setting.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + if let additionalPredicate = additionalPredicate { + fetchRequest.predicate = additionalPredicate + } + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension SettingFetchedResultController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let objects = fetchedResultsController.fetchedObjects ?? [] + self.settings.value = objects + } +} diff --git a/Mastodon/Diffiable/Item/SettingsItem.swift b/Mastodon/Diffiable/Item/SettingsItem.swift new file mode 100644 index 000000000..8aabdc741 --- /dev/null +++ b/Mastodon/Diffiable/Item/SettingsItem.swift @@ -0,0 +1,67 @@ +// +// SettingsItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import UIKit +import CoreData + +enum SettingsItem: Hashable { + case apperance(settingObjectID: NSManagedObjectID) + case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode) + case boringZone(item: Link) + case spicyZone(item: Link) +} + +extension SettingsItem { + + enum AppearanceMode: String { + case automatic + case light + case dark + } + + enum NotificationSwitchMode: CaseIterable { + case favorite + case follow + case reblog + case mention + + var title: String { + switch self { + case .favorite: return L10n.Scene.Settings.Section.Notifications.favorites + case .follow: return L10n.Scene.Settings.Section.Notifications.follows + case .reblog: return L10n.Scene.Settings.Section.Notifications.boosts + case .mention: return L10n.Scene.Settings.Section.Notifications.mentions + } + } + } + + enum Link: CaseIterable { + case termsOfService + case privacyPolicy + case clearMediaCache + case signOut + + var title: String { + switch self { + case .termsOfService: return L10n.Scene.Settings.Section.Boringzone.terms + case .privacyPolicy: return L10n.Scene.Settings.Section.Boringzone.privacy + case .clearMediaCache: return L10n.Scene.Settings.Section.Spicyzone.clear + case .signOut: return L10n.Scene.Settings.Section.Spicyzone.signout + } + } + + var textColor: UIColor { + switch self { + case .termsOfService: return .systemBlue + case .privacyPolicy: return .systemBlue + case .clearMediaCache: return .systemRed + case .signOut: return .systemRed + } + } + } + +} diff --git a/Mastodon/Diffiable/Section/SettingsSection.swift b/Mastodon/Diffiable/Section/SettingsSection.swift new file mode 100644 index 000000000..7ec78a2ed --- /dev/null +++ b/Mastodon/Diffiable/Section/SettingsSection.swift @@ -0,0 +1,24 @@ +// +// SettingsSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation + +enum SettingsSection: Hashable { + case apperance + case notifications + case boringZone + case spicyZone + + var title: String { + switch self { + case .apperance: return L10n.Scene.Settings.Section.Appearance.title + case .notifications: return L10n.Scene.Settings.Section.Notifications.title + case .boringZone: return L10n.Scene.Settings.Section.Boringzone.title + case .spicyZone: return L10n.Scene.Settings.Section.Spicyzone.title + } + } +} diff --git a/Mastodon/Extension/Array+removeDuplicates.swift b/Mastodon/Extension/Array+removeDuplicates.swift deleted file mode 100644 index c3a4b0384..000000000 --- a/Mastodon/Extension/Array+removeDuplicates.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Array+removeDuplicates.swift -// Mastodon -// -// Created by BradGao on 2021/3/31. -// - -import Foundation - -/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array -extension Array where Element: Hashable { - func removingDuplicates() -> [Element] { - var addedDict = [Element: Bool]() - - return filter { - addedDict.updateValue(true, forKey: $0) == nil - } - } - - mutating func removeDuplicates() { - self = self.removingDuplicates() - } -} diff --git a/Mastodon/Extension/Array.swift b/Mastodon/Extension/Array.swift new file mode 100644 index 000000000..42f8594d1 --- /dev/null +++ b/Mastodon/Extension/Array.swift @@ -0,0 +1,99 @@ +// +// Array.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import Foundation + +/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array +extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var addedDict = [Element: Bool]() + + return filter { + addedDict.updateValue(true, forKey: $0) == nil + } + } + + mutating func removeDuplicates() { + self = self.removingDuplicates() + } +} + +// +// CryptoSwift +// +// Copyright (C) 2014-2017 Marcin Krzyżanowski +// This software is provided 'as-is', without any express or implied warranty. +// +// In no event will the authors be held liable for any damages arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose,including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: +// +// - The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation is required. +// - Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. +// - This notice may not be removed or altered from any source or binary distribution. +// + +extension Array { + init(reserveCapacity: Int) { + self = Array() + self.reserveCapacity(reserveCapacity) + } + + var slice: ArraySlice { + self[self.startIndex ..< self.endIndex] + } +} + +extension Array where Element == UInt8 { + public init(hex: String) { + self.init(reserveCapacity: hex.unicodeScalars.lazy.underestimatedCount) + var buffer: UInt8? + var skip = hex.hasPrefix("0x") ? 2 : 0 + for char in hex.unicodeScalars.lazy { + guard skip == 0 else { + skip -= 1 + continue + } + guard char.value >= 48 && char.value <= 102 else { + removeAll() + return + } + let v: UInt8 + let c: UInt8 = UInt8(char.value) + switch c { + case let c where c <= 57: + v = c - 48 + case let c where c >= 65 && c <= 70: + v = c - 55 + case let c where c >= 97: + v = c - 87 + default: + removeAll() + return + } + if let b = buffer { + append(b << 4 | v) + buffer = nil + } else { + buffer = v + } + } + if let b = buffer { + append(b) + } + } + + public func toHexString() -> String { + `lazy`.reduce(into: "") { + var s = String($1, radix: 16) + if s.count == 1 { + s = "0" + s + } + $0 += s + } + } +} diff --git a/Mastodon/Extension/CoreDataStack/Setting.swift b/Mastodon/Extension/CoreDataStack/Setting.swift new file mode 100644 index 000000000..b995b80e3 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Setting.swift @@ -0,0 +1,24 @@ +// +// Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension Setting { + + var appearance: SettingsItem.AppearanceMode { + return SettingsItem.AppearanceMode(rawValue: appearanceRaw) ?? .automatic + } + + var activeSubscription: Subscription? { + return (subscriptions ?? Set()) + .sorted(by: { $0.activedAt > $1.activedAt }) + .first + } + +} diff --git a/Mastodon/Extension/CoreDataStack/Subscription.swift b/Mastodon/Extension/CoreDataStack/Subscription.swift new file mode 100644 index 000000000..8253264a0 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Subscription.swift @@ -0,0 +1,20 @@ +// +// Subscription.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +typealias NotificationSubscription = Subscription + +extension Subscription { + + var policy: Mastodon.API.Subscriptions.Policy { + return Mastodon.API.Subscriptions.Policy(rawValue: policyRaw) ?? .all + } + +} diff --git a/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift new file mode 100644 index 000000000..edf2df0c9 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift @@ -0,0 +1,28 @@ +// +// SubscriptionAlerts.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import Foundation +import CoreDataStack +import MastodonSDK + +extension SubscriptionAlerts.Property { + + init(policy: Mastodon.API.Subscriptions.Policy) { + switch policy { + case .all: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .follower: + self.init(favourite: true, follow: nil, followRequest: nil, mention: true, poll: true, reblog: true) + case .followed: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .none, ._other: + self.init(favourite: nil, follow: nil, followRequest: nil, mention: nil, poll: nil, reblog: nil) + } + } + +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift new file mode 100644 index 000000000..24bbfdace --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift @@ -0,0 +1,20 @@ +// +// Mastodon+API+Subscriptions+Policy.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation +import MastodonSDK + +extension Mastodon.API.Subscriptions.Policy { + var title: String { + switch self { + case .all: return L10n.Scene.Settings.Section.Notifications.Trigger.anyone + case .follower: return L10n.Scene.Settings.Section.Notifications.Trigger.follower + case .followed: return L10n.Scene.Settings.Section.Notifications.Trigger.follow + case .none, ._other: return L10n.Scene.Settings.Section.Notifications.Trigger.noone + } + } +} diff --git a/Mastodon/Extension/String.swift b/Mastodon/Extension/String.swift index 87028ffdf..bf70c8937 100644 --- a/Mastodon/Extension/String.swift +++ b/Mastodon/Extension/String.swift @@ -16,3 +16,25 @@ extension String { self = self.capitalizingFirstLetter() } } + +extension String { + static func normalize(base64String: String) -> String { + let base64 = base64String + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + .padding() + return base64 + } + + private func padding() -> String { + let remainder = self.count % 4 + if remainder > 0 { + return self.padding( + toLength: self.count + 4 - remainder, + withPad: "=", + startingAt: 0 + ) + } + return self + } +} diff --git a/Mastodon/Extension/UIViewController.swift b/Mastodon/Extension/UIViewController.swift index c3782fa14..9ebb3a0a8 100644 --- a/Mastodon/Extension/UIViewController.swift +++ b/Mastodon/Extension/UIViewController.swift @@ -46,6 +46,53 @@ extension UIViewController { } +extension UIViewController { + + func viewController(of type: T.Type) -> T? { + if let viewController = self as? T { + return viewController + } + + // UITabBarController + if let tabBarController = self as? UITabBarController { + for tab in tabBarController.viewControllers ?? [] { + if let viewController = tab.viewController(of: type) { + return viewController + } + } + } + + // UINavigationController + if let navigationController = self as? UINavigationController { + for page in navigationController.viewControllers { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // UIPageController + if let pageViewController = self as? UIPageViewController { + for page in pageViewController.viewControllers ?? [] { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // child view controller + for subview in self.view?.subviews ?? [] { + if let childViewController = subview.next as? UIViewController, + let viewController = childViewController.viewController(of: type) { + return viewController + } + } + + return nil + } + +} + extension UIViewController { /// https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/ diff --git a/Mastodon/Extension/UserDefaults.swift b/Mastodon/Extension/UserDefaults.swift new file mode 100644 index 000000000..619d6c250 --- /dev/null +++ b/Mastodon/Extension/UserDefaults.swift @@ -0,0 +1,28 @@ +// +// UserDefaults.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation +import AppShared + +extension UserDefaults { + + subscript(key: String) -> T? { + get { + if let rawValue = value(forKey: key) as? T.RawValue { + return T(rawValue: rawValue) + } + return nil + } + set { set(newValue?.rawValue, forKey: key) } + } + + subscript(key: String) -> T? { + get { return value(forKey: key) as? T } + set { set(newValue, forKey: key) } + } + +} diff --git a/Mastodon/Mastodon.entitlements b/Mastodon/Mastodon.entitlements index d334a5e6d..8917adbf4 100644 --- a/Mastodon/Mastodon.entitlements +++ b/Mastodon/Mastodon.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.security.application-groups group.org.joinmastodon.mastodon-temp diff --git a/Mastodon/Preference/AppearancePreference.swift b/Mastodon/Preference/AppearancePreference.swift new file mode 100644 index 000000000..78cf3d332 --- /dev/null +++ b/Mastodon/Preference/AppearancePreference.swift @@ -0,0 +1,20 @@ +// +// AppearancePreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var customUserInterfaceStyle: UIUserInterfaceStyle { + get { + register(defaults: [#function: UIUserInterfaceStyle.unspecified.rawValue]) + return UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified + } + set { self[#function] = newValue.rawValue } + } + +} diff --git a/Mastodon/Preference/NotificationPreference.swift b/Mastodon/Preference/NotificationPreference.swift new file mode 100644 index 000000000..289cd1fdf --- /dev/null +++ b/Mastodon/Preference/NotificationPreference.swift @@ -0,0 +1,20 @@ +// +// NotificationPreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var notificationBadgeCount: Int { + get { + register(defaults: [#function: 0]) + return integer(forKey: #function) + } + set { self[#function] = newValue } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index ff83e576e..152bb62f0 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -336,9 +336,10 @@ extension HomeTimelineViewController { } @objc private func showSettings(_ sender: UIAction) { - let viewModel = SettingsViewModel(context: context) + guard let currentSetting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) coordinator.present( - scene: .settings(viewModel: viewModel), + scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil) ) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index bd559eed5..3329ce8db 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -97,14 +97,8 @@ extension HomeTimelineViewController { // long press to trigger debug menu settingBarButtonItem.menu = debugMenu #else - // settingBarButtonItem.target = self - // settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) - settingBarButtonItem.menu = UIMenu(title: "Settings", image: nil, identifier: nil, options: .displayInline, children: [ - UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in - guard let self = self else { return } - self.signOutAction(action) - } - ]) + settingBarButtonItem.target = self + settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) #endif navigationItem.rightBarButtonItem = composeBarButtonItem @@ -296,7 +290,9 @@ extension HomeTimelineViewController { @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 9d609234f..5fd4c8256 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -85,7 +85,6 @@ class MainTabBarController: UITabBarController { extension MainTabBarController { - open override var childForStatusBarStyle: UIViewController? { return selectedViewController } @@ -156,9 +155,36 @@ extension MainTabBarController { } .store(in: &disposeBag) - #if DEBUG - // selectedIndex = 3 - #endif + // handle push notification. toggle entry when finish fetch latest notification + context.notificationService.hasUnreadPushNotification + .receive(on: DispatchQueue.main) + .sink { [weak self] hasUnreadPushNotification in + guard let self = self else { return } + guard let notificationViewController = self.notificationViewController else { return } + + let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")! + notificationViewController.tabBarItem.image = image + notificationViewController.navigationController?.tabBarItem.image = image + } + .store(in: &disposeBag) + + context.notificationService.requestRevealNotificationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] notificationID in + guard let self = self else { return } + self.coordinator.switchToTabBar(tab: .notification) + let threadViewModel = RemoteThreadViewModel(context: self.context, notificationID: notificationID) + self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + } + .store(in: &disposeBag) } } + +extension MainTabBarController { + + var notificationViewController: NotificationViewController? { + return viewController(of: NotificationViewController.self) + } + +} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 57b5dc639..b27c45817 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -88,6 +88,11 @@ extension NotificationViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) + // fetch latest if has unread push notification + if context.notificationService.hasUnreadPushNotification.value { + viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 0e6b0d622..b9d60ae7c 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -61,23 +61,25 @@ extension NotificationViewModel.LoadLatestState { query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) - .sink { completion in - switch completion { - case .failure(let error): - viewModel.isFetchingLatestNotification.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - - stateMachine.enter(Idle.self) - } receiveValue: { response in - if response.value.isEmpty { - viewModel.isFetchingLatestNotification.value = false - } + .sink { completion in + switch completion { + case .failure(let error): + viewModel.isFetchingLatestNotification.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // toggle unread state + viewModel.context.notificationService.hasUnreadPushNotification.value = false + // handle isFetchingLatestTimeline in fetch controller delegate + break } - .store(in: &viewModel.disposeBag) + + stateMachine.enter(Idle.self) + } receiveValue: { response in + if response.value.isEmpty { + viewModel.isFetchingLatestNotification.value = false + } + } + .store(in: &viewModel.disposeBag) } } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 8fc915a0a..e4be1eb1f 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -517,7 +517,9 @@ extension ProfileViewController { @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 156321d89..3c4a101a0 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -11,11 +11,10 @@ import Combine import ActiveLabel import CoreData import CoreDataStack +import MastodonSDK import AlamofireImage import Kingfisher -// iTODO: when to ask permission to Use Notifications - class SettingsViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -23,6 +22,7 @@ class SettingsViewController: UIViewController, NeedsDependency { var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() + var notificationPolicySubscription: AnyCancellable? var triggerMenu: UIMenu { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone @@ -35,27 +35,27 @@ class SettingsViewController: UIViewController, NeedsDependency { options: .displayInline, children: [ UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in - self?.updateTrigger(by: anyone) + self?.updateTrigger(policy: .all) }, UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in - self?.updateTrigger(by: follower) + self?.updateTrigger(policy: .follower) }, UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in - self?.updateTrigger(by: follow) + self?.updateTrigger(policy: .followed) }, UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in - self?.updateTrigger(by: noOne) + self?.updateTrigger(policy: .none) }, ] ) return menu } - lazy var notifySectionHeader: UIView = { + private(set) lazy var notifySectionHeader: UIView = { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true - view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) + //view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) view.axis = .horizontal view.alignment = .fill view.distribution = .equalSpacing @@ -71,15 +71,12 @@ class SettingsViewController: UIViewController, NeedsDependency { return view }() - lazy var whoButton: UIButton = { + private(set) lazy var whoButton: UIButton = { let whoButton = UIButton(type: .roundedRect) whoButton.menu = triggerMenu whoButton.showsMenuAsPrimaryAction = true whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) - if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { - whoButton.setTitle(trigger, for: .normal) - } whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) whoButton.layer.cornerRadius = 10 @@ -87,7 +84,7 @@ class SettingsViewController: UIViewController, NeedsDependency { return whoButton }() - lazy var tableView: UITableView = { + private(set) lazy var tableView: UITableView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) tableView.translatesAutoresizingMaskIntoConstraints = false @@ -95,13 +92,13 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.rowHeight = UITableView.automaticDimension tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell") - tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell") - tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell") + tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self)) + tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self)) + tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsLinkTableViewCell.self)) return tableView }() - lazy var footerView: UIView = { + lazy var tableFooterView: UIView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0) let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320)) view.isLayoutMarginsRelativeArrangement = true @@ -143,14 +140,30 @@ class SettingsViewController: UIViewController, NeedsDependency { // MAKR: - Private methods private func bindViewModel() { - let input = SettingsViewModel.Input() - _ = viewModel.transform(input: input) + self.whoButton.setTitle(viewModel.setting.value.activeSubscription?.policy.title, for: .normal) + viewModel.setting + .sink { [weak self] setting in + guard let self = self else { return } + self.notificationPolicySubscription = ManagedObjectObserver.observe(object: setting) + .sink { _ in + // do nothing + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard case let .update(object) = change.changeType, + let setting = object as? Setting else { return } + if let activeSubscription = setting.activeSubscription { + self.whoButton.setTitle(activeSubscription.policy.title, for: .normal) + } else { + assertionFailure() + } + } + } + .store(in: &disposeBag) } private func setupView() { view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color setupNavigation() - setupTableView() view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -159,6 +172,7 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + setupTableView() } private func setupNavigation() { @@ -177,35 +191,12 @@ class SettingsViewController: UIViewController, NeedsDependency { } private func setupTableView() { - viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in - guard let self = self else { return nil } - - switch item { - case .apperance(let item): - guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else { - assertionFailure() - return nil - } - cell.update(with: item, delegate: self) - return cell - case .notification(let item): - guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else { - assertionFailure() - return nil - } - cell.update(with: item, delegate: self) - return cell - case .boringZone(let item), .spicyZone(let item): - guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else { - assertionFailure() - return nil - } - cell.update(with: item) - return cell - } - }) - - tableView.tableFooterView = footerView + viewModel.setupDiffableDataSource( + for: tableView, + settingsAppearanceTableViewCellDelegate: self, + settingsToggleCellDelegate: self + ) + tableView.tableFooterView = tableFooterView } func alertToSignout() { @@ -218,7 +209,7 @@ class SettingsViewController: UIViewController, NeedsDependency { let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in guard let self = self else { return } - self.signout() + self.signOut() } alertController.addAction(cancelAction) alertController.addAction(signOutAction) @@ -229,7 +220,7 @@ class SettingsViewController: UIViewController, NeedsDependency { ) } - func signout() { + func signOut() { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -258,8 +249,11 @@ class SettingsViewController: UIViewController, NeedsDependency { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } - // Mark: - Actions - @objc func doneButtonDidClick() { +} + +// Mark: - Actions +extension SettingsViewController { + @objc private func doneButtonDidClick() { dismiss(animated: true, completion: nil) } } @@ -268,47 +262,39 @@ extension SettingsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let sections = viewModel.dataSource.snapshot().sectionIdentifiers guard section < sections.count else { return nil } - let sectionData = sections[section] - if section == 1 { - let header = SettingsSectionHeader( + let sectionIdentifier = sections[section] + + let header: SettingsSectionHeader + switch sectionIdentifier { + case .notifications: + header = SettingsSectionHeader( frame: CGRect(x: 0, y: 0, width: 375, height: 66), customView: notifySectionHeader) - header.update(title: sectionData.title) - - if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { - whoButton.setTitle(trigger, for: .normal) - } else { - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - whoButton.setTitle(anyone, for: .normal) - } - return header - } else { - let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) - header.update(title: sectionData.title) - return header + header.update(title: sectionIdentifier.title) + default: + header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) + header.update(title: sectionIdentifier.title) } + header.preservesSuperviewLayoutMargins = true + + return header } - + // remove the gap of table's footer func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { return UIView() } - + // remove the gap of table's footer func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return 0 + return CGFloat.leastNonzeroMagnitude } - + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let snapshot = self.viewModel.dataSource.snapshot() - let sectionIds = snapshot.sectionIdentifiers - guard indexPath.section < sectionIds.count else { return } - let sectionIdentifier = sectionIds[indexPath.section] - let items = snapshot.itemIdentifiers(inSection: sectionIdentifier) - guard indexPath.row < items.count else { return } - let item = items[indexPath.item] - + guard let dataSource = viewModel.dataSource else { return } + let item = dataSource.itemIdentifier(for: indexPath) + switch item { case .boringZone: guard let url = viewModel.privacyURL else { break } @@ -327,7 +313,7 @@ extension SettingsViewController: UITableViewDelegate { ImageDownloader.defaultURLCache().removeAllCachedResponses() let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes) - + // clean Kingfisher Cache KingfisherManager.shared.cache.clearDiskCache() } @@ -343,82 +329,77 @@ extension SettingsViewController: UITableViewDelegate { // Update setting into core data extension SettingsViewController { - func updateTrigger(by who: String) { - guard self.viewModel.triggerBy != who else { return } - guard let setting = self.viewModel.setting.value else { return } + func updateTrigger(policy: Mastodon.API.Subscriptions.Policy) { + let objectID = self.viewModel.setting.value.objectID + let managedObjectContext = context.backgroundManagedObjectContext - setting.update(triggerBy: who) - // trigger to call `subscription` API with POST method - // confirm the local data is correct even if request failed - // The asynchronous execution is to solve the problem of dropped frames for animations. - DispatchQueue.main.async { [weak self] in - self?.viewModel.setting.value = setting + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: objectID) as! Setting + let (subscription, _) = APIService.CoreData.createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + let now = Date() + subscription.update(activedAt: now) + setting.didUpdate(at: now) } - } - - func updateAlert(title: String?, isOn: Bool) { - guard let title = title else { return } - guard let settings = self.viewModel.setting.value else { return } - guard let triggerBy = settings.triggerBy else { return } - - if let alerts = settings.subscription?.first(where: { (s) -> Bool in - return s.type == settings.triggerBy - })?.alert { - var alertValues = [Bool?]() - alertValues.append(alerts.favourite?.boolValue) - alertValues.append(alerts.follow?.boolValue) - alertValues.append(alerts.reblog?.boolValue) - alertValues.append(alerts.mention?.boolValue) - - // need to update `alerts` to make update API with correct parameter - switch title { - case L10n.Scene.Settings.Section.Notifications.favorites: - alertValues[0] = isOn - alerts.favourite = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.follows: - alertValues[1] = isOn - alerts.follow = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.boosts: - alertValues[2] = isOn - alerts.reblog = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.mentions: - alertValues[3] = isOn - alerts.mention = NSNumber(booleanLiteral: isOn) - default: break - } - self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) - } else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] { - self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nohting } + .store(in: &disposeBag) } } +// MARK: - SettingsAppearanceTableViewCellDelegate extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { - func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { - guard let setting = self.viewModel.setting.value else { return } - + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + guard case let .apperance(settingObjectID) = item else { return } + context.managedObjectContext.performChanges { - setting.update(appearance: didSelect.rawValue) + let setting = self.context.managedObjectContext.object(with: settingObjectID) as! Setting + setting.update(appearanceRaw: appearanceMode.rawValue) } - .sink { (_) in - // change light / dark mode - var overrideUserInterfaceStyle: UIUserInterfaceStyle! - switch didSelect { - case .automatic: - overrideUserInterfaceStyle = .unspecified - case .light: - overrideUserInterfaceStyle = .light - case .dark: - overrideUserInterfaceStyle = .dark - } - view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + .sink { _ in + // do nothing }.store(in: &disposeBag) } } extension SettingsViewController: SettingsToggleCellDelegate { - func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) { - updateAlert(title: cell.data?.title, isOn: didChangeStatus) + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + switch item { + case .notification(let settingObjectID, let switchMode): + let isOn = `switch`.isOn + let managedObjectContext = context.backgroundManagedObjectContext + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: settingObjectID) as! Setting + guard let subscription = setting.activeSubscription else { return } + let alert = subscription.alert + switch switchMode { + case .favorite: alert.update(favourite: isOn) + case .follow: alert.update(follow: isOn) + case .reblog: alert.update(reblog: isOn) + case .mention: alert.update(mention: isOn) + } + // trigger setting update + alert.subscription.setting?.didUpdate(at: Date()) + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + default: + break + } } } @@ -432,43 +413,6 @@ extension SettingsViewController: ActiveLabelDelegate { } } -extension SettingsViewController { - static func updateOverrideUserInterfaceStyle(window: UIWindow?) { - guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - - guard let setting: Setting? = { - let domain = box.domain - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain, userID: box.userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try AppContext.shared.managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() else { return } - - guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else { - return - } - - var overrideUserInterfaceStyle: UIUserInterfaceStyle! - switch didSelect { - case .automatic: - overrideUserInterfaceStyle = .unspecified - case .light: - overrideUserInterfaceStyle = .light - case .dark: - overrideUserInterfaceStyle = .dark - } - window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle - } -} - #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 57371f92b..c168b5611 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -13,37 +13,21 @@ import MastodonSDK import UIKit import os.log -class SettingsViewModel: NSObject { - // confirm set only once - weak var context: AppContext! { willSet { precondition(context == nil) } } +class SettingsViewModel { - var dataSource: UITableViewDiffableDataSource! var disposeBag = Set() + + let context: AppContext + + // input + let setting: CurrentValueSubject var updateDisposeBag = Set() var createDisposeBag = Set() let viewDidLoad = PassthroughSubject() - lazy var fetchResultsController: NSFetchedResultsController = { - let fetchRequest = Setting.sortedFetchRequest - if let box = - self.context.authenticationService.activeMastodonAuthenticationBox.value { - let domain = box.domain - fetchRequest.predicate = Setting.predicate(domain: domain, userID: box.userID) - } - - fetchRequest.fetchLimit = 1 - fetchRequest.returnsObjectsAsFaults = false - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - controller.delegate = self - return controller - }() - let setting = CurrentValueSubject(nil) + // output + var dataSource: UITableViewDiffableDataSource! /// create a subscription when: /// - does not has one /// - does not find subscription for selected trigger when change trigger @@ -53,22 +37,6 @@ class SettingsViewModel: NSObject { /// - change switch for specified alerts let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() - lazy var notificationDefaultValue: [String: [Bool?]] = { - let followerSwitchItems: [Bool?] = [true, nil, true, true] - let anyoneSwitchItems: [Bool?] = [true, true, true, true] - let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil] - let followSwitchItems: [Bool?] = [true, true, true, true] - - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower - let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow - let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone - return [anyone: anyoneSwitchItems, - follower: followerSwitchItems, - follow: followSwitchItems, - noOne: noOneSwitchItems] - }() - lazy var privacyURL: URL? = { guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { return nil @@ -77,315 +45,151 @@ class SettingsViewModel: NSObject { return Mastodon.API.privacyURL(domain: box.domain) }() - /// to store who trigger the notification. - var triggerBy: String? - - struct Input { - } - - struct Output { - } - - init(context: AppContext) { + init(context: AppContext, setting: Setting) { self.context = context + self.setting = CurrentValueSubject(setting) - super.init() - } - - func transform(input: Input?) -> Output? { - typealias SubscriptionResponse = Mastodon.Response.Content - createSubscriptionSubject - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { _ in - } receiveValue: { [weak self] (arg) in - let (triggerBy, values) = arg - guard let self = self else { - return - } - guard let activeMastodonAuthenticationBox = - self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - guard values.count >= 4 else { - return - } - - self.createDisposeBag.removeAll() - typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery - let domain = activeMastodonAuthenticationBox.domain - let query = Query( - // FIXME: to replace the correct endpoint, p256dh, auth - endpoint: "http://www.google.com", - p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", - auth: "4vQK-SvRAN5eo-8ASlrwA==", - favourite: values[0], - follow: values[1], - reblog: values[2], - mention: values[3], - poll: nil - ) - self.context.apiService.changeSubscription( - domain: domain, - mastodonAuthenticationBox: activeMastodonAuthenticationBox, - query: query, - triggerBy: triggerBy, - userID: activeMastodonAuthenticationBox.userID - ) - .sink { (_) in - } receiveValue: { (_) in - } - .store(in: &self.createDisposeBag) - } + self.setting + .sink(receiveValue: { [weak self] setting in + guard let self = self else { return } + self.processDataSource(setting) + }) .store(in: &disposeBag) - - updateSubscriptionSubject - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { _ in - } receiveValue: { [weak self] (arg) in - let (triggerBy, values) = arg - guard let self = self else { - return - } - guard let activeMastodonAuthenticationBox = - self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - guard values.count >= 4 else { - return - } - - self.updateDisposeBag.removeAll() - typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery - let domain = activeMastodonAuthenticationBox.domain - let query = Query( - favourite: values[0], - follow: values[1], - reblog: values[2], - mention: values[3], - poll: nil) - self.context.apiService.updateSubscription( - domain: domain, - mastodonAuthenticationBox: activeMastodonAuthenticationBox, - query: query, - triggerBy: triggerBy, - userID: activeMastodonAuthenticationBox.userID - ) - .sink { (_) in - } receiveValue: { (_) in - } - .store(in: &self.updateDisposeBag) - } - .store(in: &disposeBag) - - // build data for table view - buildDataSource() - - // request subsription data for updating or initialization - requestSubscription() - return nil - } - - // MARK: - Private methods - fileprivate func processDataSource(_ settings: Setting?) { - var snapshot = NSDiffableDataSourceSnapshot() - - // appearance - let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic - let appearanceItem = SettingsItem.apperance(item: appearnceMode) - let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem) - snapshot.appendSections([appearance]) - snapshot.appendItems([appearanceItem]) - - // notifications - var switches: [Bool?]? - if let alerts = settings?.subscription?.first(where: { (s) -> Bool in - return s.type == settings?.triggerBy - })?.alert { - var items = [Bool?]() - items.append(alerts.favourite?.boolValue) - items.append(alerts.follow?.boolValue) - items.append(alerts.reblog?.boolValue) - items.append(alerts.mention?.boolValue) - switches = items - } else if let triggerBy = settings?.triggerBy, - let values = self.notificationDefaultValue[triggerBy] { - switches = values - } else { - // fallback a default value - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - switches = self.notificationDefaultValue[anyone] - } - - let notifications = [L10n.Scene.Settings.Section.Notifications.favorites, - L10n.Scene.Settings.Section.Notifications.follows, - L10n.Scene.Settings.Section.Notifications.boosts, - L10n.Scene.Settings.Section.Notifications.mentions,] - var notificationItems = [SettingsItem]() - for (i, noti) in notifications.enumerated() { - var value: Bool? = nil - if let switches = switches, i < switches.count { - value = switches[i] - } - - let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil)) - notificationItems.append(item) - } - let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems) - snapshot.appendSections([notificationSection]) - snapshot.appendItems(notificationItems) - - // boring zone - let boringLinks = [L10n.Scene.Settings.Section.Boringzone.terms, - L10n.Scene.Settings.Section.Boringzone.privacy] - var boringLinkItems = [SettingsItem]() - for l in boringLinks { - let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue)) - boringLinkItems.append(item) - } - let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Boringzone.title, items: boringLinkItems) - snapshot.appendSections([boringSection]) - snapshot.appendItems(boringLinkItems) - - // spicy zone - let spicyLinks = [L10n.Scene.Settings.Section.Spicyzone.clear, - L10n.Scene.Settings.Section.Spicyzone.signout] - var spicyLinkItems = [SettingsItem]() - for l in spicyLinks { - let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed)) - spicyLinkItems.append(item) - } - let spicySection = SettingsSection.spicyZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems) - snapshot.appendSections([spicySection]) - snapshot.appendItems(spicyLinkItems) - - self.dataSource.apply(snapshot, animatingDifferences: false) - } - - private func buildDataSource() { - setting.sink { [weak self] (settings) in - guard let self = self else { return } - self.processDataSource(settings) - } - .store(in: &disposeBag) - } - - private func requestSubscription() { - setting.sink { [weak self] (settings) in - guard let self = self else { return } - guard settings != nil else { return } - guard self.triggerBy != settings?.triggerBy else { return } - self.triggerBy = settings?.triggerBy - - var switches: [Bool?]? - var who: String? - if let alerts = settings?.subscription?.first(where: { (s) -> Bool in - return s.type == settings?.triggerBy - })?.alert { - var items = [Bool?]() - items.append(alerts.favourite?.boolValue) - items.append(alerts.follow?.boolValue) - items.append(alerts.reblog?.boolValue) - items.append(alerts.mention?.boolValue) - switches = items - who = settings?.triggerBy - } else if let triggerBy = settings?.triggerBy, - let values = self.notificationDefaultValue[triggerBy] { - switches = values - who = triggerBy - } else { - // fallback a default value - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - switches = self.notificationDefaultValue[anyone] - who = anyone - } - - // should create a subscription whenever change trigger - if let values = switches, let triggerBy = who { - self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values)) - } - } - .store(in: &disposeBag) - - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let domain = activeMastodonAuthenticationBox.domain - let userId = activeMastodonAuthenticationBox.userID - - do { - try fetchResultsController.performFetch() - if nil == fetchResultsController.fetchedObjects?.first { - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - setting.value = self.context.apiService.createSettingIfNeed(domain: domain, - userId: userId, - triggerBy: anyone) - } else { - setting.value = fetchResultsController.fetchedObjects?.first - } - } catch { - assertionFailure(error.localizedDescription) - } } deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } + } -// MARK: - NSFetchedResultsControllerDelegate -extension SettingsViewModel: NSFetchedResultsControllerDelegate { +extension SettingsViewModel { - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } + // MARK: - Private methods + private func processDataSource(_ setting: Setting) { + guard let dataSource = self.dataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - guard controller === fetchResultsController else { - return + // appearance + let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)] + snapshot.appendSections([.apperance]) + snapshot.appendItems(appearanceItems, toSection: .apperance) + + let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in + SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode) + } + snapshot.appendSections([.notifications]) + snapshot.appendItems(notificationItems, toSection: .notifications) + + // boring zone + let boringZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .termsOfService, + .privacyPolicy + ] + let items = links.map { SettingsItem.boringZone(item: $0) } + return items + }() + snapshot.appendSections([.boringZone]) + snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone) + + let spicyZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .clearMediaCache, + .signOut + ] + let items = links.map { SettingsItem.spicyZone(item: $0) } + return items + }() + snapshot.appendSections([.spicyZone]) + snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone) + + dataSource.apply(snapshot, animatingDifferences: false) + } + +} + +extension SettingsViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate, + settingsToggleCellDelegate: SettingsToggleCellDelegate + ) { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [ + weak self, + weak settingsAppearanceTableViewCellDelegate, + weak settingsToggleCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .apperance(let objectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + cell.update(with: setting.appearance) + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + cell.update(with: setting.appearance) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsAppearanceTableViewCellDelegate + return cell + case .notification(let objectID, let switchMode): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + if let subscription = setting.activeSubscription { + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + } + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + guard let subscription = setting.activeSubscription else { return } + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsToggleCellDelegate + return cell + case .boringZone(let item), .spicyZone(let item): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell + cell.update(with: item) + return cell + } } - setting.value = fetchResultsController.fetchedObjects?.first + processDataSource(self.setting.value) } - } -enum SettingsSection: Hashable { - case apperance(title: String, selectedMode: SettingsItem) - case notifications(title: String, items: [SettingsItem]) - case boringZone(title: String, items: [SettingsItem]) - case spicyZone(title: String, items: [SettingsItem]) +extension SettingsViewModel { - var title: String { - switch self { - case .apperance(let title, _), - .notifications(let title, _), - .boringZone(let title, _), - .spicyZone(let title, _): - return title + static func configureSettingToggle( + cell: SettingsToggleTableViewCell, + switchMode: SettingsItem.NotificationSwitchMode, + subscription: NotificationSubscription + ) { + cell.textLabel?.text = switchMode.title + + let enabled: Bool? + switch switchMode { + case .favorite: enabled = subscription.alert.favourite + case .follow: enabled = subscription.alert.follow + case .reblog: enabled = subscription.alert.reblog + case .mention: enabled = subscription.alert.mention } + cell.update(enabled: enabled) } -} - -enum SettingsItem: Hashable { - enum AppearanceMode: String { - case automatic - case light - case dark - } - - struct NotificationSwitch: Hashable { - let title: String - let isOn: Bool - let enable: Bool - } - - struct Link: Hashable { - let title: String - let color: UIColor - } - - case apperance(item: AppearanceMode) - case notification(item: NotificationSwitch) - case boringZone(item: Link) - case spicyZone(item: Link) + } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift index a477661ee..44a7e7574 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -6,9 +6,10 @@ // import UIKit +import Combine protocol SettingsAppearanceTableViewCellDelegate: class { - func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) } class AppearanceView: UIView { @@ -85,6 +86,9 @@ class AppearanceView: UIView { } class SettingsAppearanceTableViewCell: UITableViewCell { + + var disposeBag = Set() + weak var delegate: SettingsAppearanceTableViewCellDelegate? var appearance: SettingsItem.AppearanceMode = .automatic @@ -123,6 +127,12 @@ class SettingsAppearanceTableViewCell: UITableViewCell { tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) return tapGestureRecognizer }() + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + } // MARK: - Methods override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -145,9 +155,8 @@ class SettingsAppearanceTableViewCell: UITableViewCell { } } - func update(with data: SettingsItem.AppearanceMode, delegate: SettingsAppearanceTableViewCellDelegate?) { + func update(with data: SettingsItem.AppearanceMode) { appearance = data - self.delegate = delegate automatic.selected = false light.selected = false @@ -200,6 +209,6 @@ class SettingsAppearanceTableViewCell: UITableViewCell { } guard let delegate = self.delegate else { return } - delegate.settingsAppearanceCell(self, didSelect: appearance) + delegate.settingsAppearanceCell(self, didSelectAppearanceMode: appearance) } } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift index b5d0306d4..7fdbf7f02 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift @@ -8,6 +8,7 @@ import UIKit class SettingsLinkTableViewCell: UITableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -22,10 +23,13 @@ class SettingsLinkTableViewCell: UITableViewCell { super.setHighlighted(highlighted, animated: animated) textLabel?.alpha = highlighted ? 0.6 : 1.0 } - - // MARK: - Methods - func update(with data: SettingsItem.Link) { - textLabel?.text = data.title - textLabel?.textColor = data.color + +} + +// MARK: - Methods +extension SettingsLinkTableViewCell { + func update(with link: SettingsItem.Link) { + textLabel?.text = link.title + textLabel?.textColor = link.textColor } } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift index b35b2b50f..b4a62635b 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -6,18 +6,21 @@ // import UIKit +import Combine protocol SettingsToggleCellDelegate: class { - func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) } class SettingsToggleTableViewCell: UITableViewCell { - lazy var switchButton: UISwitch = { + + var disposeBag = Set() + + private(set) lazy var switchButton: UISwitch = { let view = UISwitch(frame:.zero) return view }() - var data: SettingsItem.NotificationSwitch? weak var delegate: SettingsToggleCellDelegate? // MARK: - Methods @@ -27,21 +30,8 @@ class SettingsToggleTableViewCell: UITableViewCell { } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) { - self.delegate = delegate - self.data = data - textLabel?.text = data.title - switchButton.isOn = data.isOn - setup(enable: data.enable) - } - - // MARK: Actions - @objc func valueDidChange(sender: UISwitch) { - guard let delegate = delegate else { return } - delegate.settingsToggleCell(self, didChangeStatus: sender.isOn) + super.init(coder: coder) + setupUI() } // MARK: Private methods @@ -49,15 +39,27 @@ class SettingsToggleTableViewCell: UITableViewCell { selectionStyle = .none accessoryView = switchButton - switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged) + switchButton.addTarget(self, action: #selector(switchValueDidChange(sender:)), for: .valueChanged) + } + +} + +// MARK: - Actions +extension SettingsToggleTableViewCell { + + @objc private func switchValueDidChange(sender: UISwitch) { + guard let delegate = delegate else { return } + delegate.settingsToggleCell(self, switchValueDidChange: sender) + } + +} + +extension SettingsToggleTableViewCell { + + func update(enabled: Bool?) { + switchButton.isEnabled = enabled != nil + textLabel?.textColor = enabled != nil ? Asset.Colors.Label.primary.color : Asset.Colors.Label.secondary.color + switchButton.isOn = enabled ?? false } - private func setup(enable: Bool) { - if enable { - textLabel?.textColor = Asset.Colors.Label.primary.color - } else { - textLabel?.textColor = Asset.Colors.Label.secondary.color - } - switchButton.isEnabled = enable - } } diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift index e79c355cf..e6e111018 100644 --- a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -47,4 +47,43 @@ final class RemoteThreadViewModel: ThreadViewModel { } .store(in: &disposeBag) } + + // FIXME: multiple account supports + init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { + super.init(context: context, optionalStatus: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + context.apiService.notification( + notificationID: notificationID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s fetched", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + guard let statusID = response.value.status?.id else { return } + + let managedObjectContext = context.managedObjectContext + let request = Status.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = Status.predicate(domain: domain, id: statusID) + guard let status = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute()) + } + .store(in: &disposeBag) + } + } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index ee8f5186c..590842ce1 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -64,4 +64,48 @@ extension APIService { } .eraseToAnyPublisher() } + + func notification( + notificationID: Mastodon.Entity.Notification.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Notifications.getNotification( + session: session, + domain: domain, + notificationID: notificationID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + guard let status = response.value.status else { + return Just(response) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response.map { _ in [status] }, + persistType: .lookUp, + requestMastodonUserID: nil, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + } diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index 337ab26d2..ceaff45fa 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -5,6 +5,7 @@ // Created by ihugo on 2021/4/9. // +import os.log import Combine import CoreData import CoreDataStack @@ -13,63 +14,14 @@ import MastodonSDK extension APIService { - func subscription( - domain: String, - userID: String, + func createSubscription( + subscriptionObjectID: NSManagedObjectID, + query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization + let domain = mastodonAuthenticationBox.domain - let findSettings: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain, userID: userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try self.backgroundManagedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - let triggerBy = findSettings?.triggerBy ?? "anyone" - let setting = self.createSettingIfNeed( - domain: domain, - userId: userID, - triggerBy: triggerBy - ) - return Mastodon.API.Subscriptions.subscription( - session: session, - domain: domain, - authorization: authorization - ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain, - triggerBy: triggerBy, - setting: setting) - } - .setFailureType(to: Error.self) - .map { _ in return response } - .eraseToAnyPublisher() - }.eraseToAnyPublisher() - } - - func changeSubscription( - domain: String, - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, - triggerBy: String, - userID: String - ) -> AnyPublisher, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - - let setting = self.createSettingIfNeed(domain: domain, - userId: userID, - triggerBy: triggerBy) return Mastodon.API.Subscriptions.createSubscription( session: session, domain: domain, @@ -77,87 +29,42 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain, - triggerBy: triggerBy, - setting: setting - ) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.endpoint) + + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + guard let subscription = managedObjectContext.object(with: subscriptionObjectID) as? NotificationSubscription else { + assertionFailure() + return + } + subscription.endpoint = response.value.endpoint + subscription.serverKey = response.value.serverKey + subscription.userToken = authorization.accessToken + subscription.didUpdate(at: response.networkDate) } .setFailureType(to: Error.self) .map { _ in return response } .eraseToAnyPublisher() - }.eraseToAnyPublisher() + } + .eraseToAnyPublisher() } - func updateSubscription( - domain: String, - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery, - triggerBy: String, - userID: String - ) -> AnyPublisher, Error> { + func cancelSubscription( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - - let setting = self.createSettingIfNeed(domain: domain, - userId: userID, - triggerBy: triggerBy) - - return Mastodon.API.Subscriptions.updateSubscription( + let domain = mastodonAuthenticationBox.domain + + return Mastodon.API.Subscriptions.removeSubscription( session: session, domain: domain, - authorization: authorization, - query: query + authorization: authorization ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain, - triggerBy: triggerBy, - setting: setting - ) - } - .setFailureType(to: Error.self) - .map { _ in return response } - .eraseToAnyPublisher() - }.eraseToAnyPublisher() - } - - func createSettingIfNeed(domain: String, userId: String, triggerBy: String) -> Setting { - // create setting entity if possible - let oldSetting: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain, userID: userId) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try backgroundManagedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - var setting: Setting! - if let oldSetting = oldSetting { - setting = oldSetting - } else { - let property = Setting.Property( - appearance: "automatic", - triggerBy: triggerBy, - domain: domain, - userID: userId) - (setting, _) = APIService.CoreData.createOrMergeSetting( - into: backgroundManagedObjectContext, - domain: domain, - userID: userId, - property: property - ) - } - return setting + .handleEvents(receiveOutput: { _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function) + }) + .eraseToAnyPublisher() } + } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift new file mode 100644 index 000000000..0c23eab6e --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift @@ -0,0 +1,76 @@ +// +// APIService+CoreData+Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeSetting( + into managedObjectContext: NSManagedObjectContext, + property: Setting.Property + ) -> (Subscription: Setting, isCreated: Bool) { + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: property.domain, userID: property.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + return managedObjectContext.safeFetch(request).first + }() + + if let oldSetting = oldSetting { + setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: oldSetting) + return (oldSetting, false) + } else { + let setting = Setting.insert( + into: managedObjectContext, + property: property + ) + setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: setting) + return (setting, true) + } + } + +} + +extension APIService.CoreData { + + static func setupSettingSubscriptions( + managedObjectContext: NSManagedObjectContext, + setting: Setting + ) { + guard (setting.subscriptions ?? Set()).isEmpty else { return } + + let now = Date() + let policies: [Mastodon.API.Subscriptions.Policy] = [ + .all, + .followed, + .follower, + .none + ] + policies.forEach { policy in + let (subscription, _) = createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + if policy == .all { + subscription.update(activedAt: now) + } else { + subscription.update(activedAt: now.addingTimeInterval(-10)) + } + } + + // trigger setting update + setting.didUpdate(at: now) + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift index f5a4022ea..6eebc9e56 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -13,96 +13,50 @@ import MastodonSDK extension APIService.CoreData { - static func createOrMergeSetting( + static func createOrFetchSubscription( into managedObjectContext: NSManagedObjectContext, - domain: String, - userID: String, - property: Setting.Property - ) -> (Subscription: Setting, isCreated: Bool) { - let oldSetting: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: property.domain, userID: userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - - if let oldSetting = oldSetting { - return (oldSetting, false) - } else { - let setting = Setting.insert( - into: managedObjectContext, - property: property) - return (setting, true) - } - } - - static func createOrMergeSubscription( - into managedObjectContext: NSManagedObjectContext, - entity: Mastodon.Entity.Subscription, - domain: String, - triggerBy: String, - setting: Setting - ) -> (Subscription: Subscription, isCreated: Bool) { + setting: Setting, + policy: Mastodon.API.Subscriptions.Policy + ) -> (subscription: Subscription, isCreated: Bool) { let oldSubscription: Subscription? = { let request = Subscription.sortedFetchRequest - request.predicate = Subscription.predicate(type: triggerBy) + request.predicate = Subscription.predicate(policyRaw: policy.rawValue) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } + return managedObjectContext.safeFetch(request).first }() - let property = Subscription.Property( - endpoint: entity.endpoint, - id: entity.id, - serverKey: entity.serverKey, - type: triggerBy - ) - let alertEntity = entity.alerts - let alert = SubscriptionAlerts.Property( - favourite: alertEntity.favouriteNumber, - follow: alertEntity.followNumber, - mention: alertEntity.mentionNumber, - poll: alertEntity.pollNumber, - reblog: alertEntity.reblogNumber - ) if let oldSubscription = oldSubscription { - oldSubscription.updateIfNeed(property: property) - if nil == oldSubscription.alert { - oldSubscription.alert = SubscriptionAlerts.insert( - into: managedObjectContext, - property: alert - ) - } else { - oldSubscription.alert?.updateIfNeed(property: alert) - } - - if oldSubscription.alert?.hasChanges == true || oldSubscription.hasChanges { - // don't expand subscription if add existed subscription - //setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription) - oldSubscription.didUpdate(at: Date()) - } + oldSubscription.setting = setting return (oldSubscription, false) } else { + let subscriptionProperty = Subscription.Property(policyRaw: policy.rawValue) let subscription = Subscription.insert( into: managedObjectContext, - property: property + property: subscriptionProperty, + setting: setting ) + let alertProperty = SubscriptionAlerts.Property(policy: policy) subscription.alert = SubscriptionAlerts.insert( into: managedObjectContext, - property: alert) - setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription) + property: alertProperty, + subscription: subscription + ) + return (subscription, true) } } + +} + +extension APIService.CoreData { + + static func merge( + subscription: Subscription, + property: Subscription.Property, + networkDate: Date + ) { + // TODO: + } + } diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 89ce7a182..6b35486d6 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -15,6 +15,7 @@ import MastodonSDK final class AuthenticationService: NSObject { var disposeBag = Set() + // input weak var apiService: APIService? let managedObjectContext: NSManagedObjectContext // read-only @@ -23,6 +24,7 @@ final class AuthenticationService: NSObject { // output let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([]) + let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) let activeMastodonAuthentication = CurrentValueSubject(nil) let activeMastodonAuthenticationBox = CurrentValueSubject(nil) @@ -58,16 +60,24 @@ final class AuthenticationService: NSObject { .assign(to: \.value, on: activeMastodonAuthentication) .store(in: &disposeBag) - activeMastodonAuthentication - .map { authentication -> AuthenticationService.MastodonAuthenticationBox? in - guard let authentication = authentication else { return nil } - return AuthenticationService.MastodonAuthenticationBox( - domain: authentication.domain, - userID: authentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) - ) + mastodonAuthentications + .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in + return authentications + .sorted(by: { $0.activedAt > $1.activedAt }) + .compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in + return AuthenticationService.MastodonAuthenticationBox( + domain: authentication.domain, + userID: authentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) + ) + } } + .assign(to: \.value, on: mastodonAuthenticationBoxes) + .store(in: &disposeBag) + + mastodonAuthenticationBoxes + .map { $0.first } .assign(to: \.value, on: activeMastodonAuthenticationBox) .store(in: &disposeBag) @@ -114,16 +124,37 @@ extension AuthenticationService { func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { var isSignOut = false - return backgroundManagedObjectContext.performChanges { + var _mastodonAutenticationBox: MastodonAuthenticationBox? + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.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 { + guard let mastodonAutentication = try? managedObjectContext.fetch(request).first else { return } - self.backgroundManagedObjectContext.delete(mastodonAutentication) + _mastodonAutenticationBox = AuthenticationService.MastodonAuthenticationBox( + domain: mastodonAutentication.domain, + userID: mastodonAutentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.userAccessToken) + ) + managedObjectContext.delete(mastodonAutentication) isSignOut = true } + .flatMap { result -> AnyPublisher, Never> in + guard let apiService = self.apiService, + let mastodonAuthenticationBox = _mastodonAutenticationBox else { + return Just(result).eraseToAnyPublisher() + } + + return apiService.cancelSubscription( + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + .map { _ in result } + .catch { _ in Just(result).eraseToAnyPublisher() } + .eraseToAnyPublisher() + } .map { result in return result.map { isSignOut } } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift new file mode 100644 index 000000000..e21a3cff8 --- /dev/null +++ b/Mastodon/Service/NotificationService.swift @@ -0,0 +1,204 @@ +// +// NotificationService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-22. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import AppShared + +final class NotificationService { + + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + let isNotificationPermissionGranted = CurrentValueSubject(false) + let deviceToken = CurrentValueSubject(nil) + + // output + /// [Token: UserID] + let notificationSubscriptionDict: [String: NotificationViewModel] = [:] + let hasUnreadPushNotification = CurrentValueSubject(false) + let requestRevealNotificationPublisher = PassthroughSubject() + + init( + apiService: APIService, + authenticationService: AuthenticationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + + authenticationService.mastodonAuthentications + .sink(receiveValue: { [weak self] mastodonAuthentications in + guard let self = self else { return } + + // request permission when sign-in + guard !mastodonAuthentications.isEmpty else { return } + self.requestNotificationPermission() + }) + .store(in: &disposeBag) + + deviceToken + .receive(on: DispatchQueue.main) + .sink { [weak self] deviceToken in + guard let _ = self else { return } + guard let deviceToken = deviceToken else { return } + let token = [UInt8](deviceToken).toHexString() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token) + } + .store(in: &disposeBag) + } + +} + +extension NotificationService { + private func requestNotificationPermission() { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: request notification permission: %s", ((#file as NSString).lastPathComponent), #line, #function, granted ? "granted" : "fail") + + self.isNotificationPermissionGranted.value = granted + + if let _ = error { + // Handle the error here. + } + + // Enable or disable features based on the authorization. + } + } +} + +extension NotificationService { + + func dequeueNotificationViewModel( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> NotificationViewModel? { + var _notificationSubscription: NotificationViewModel? + workingQueue.sync { + let domain = mastodonAuthenticationBox.domain + let userID = mastodonAuthenticationBox.userID + let key = [domain, userID].joined(separator: "@") + + if let notificationSubscription = notificationSubscriptionDict[key] { + _notificationSubscription = notificationSubscription + } else { + let notificationSubscription = NotificationViewModel(domain: domain, userID: userID) + _notificationSubscription = notificationSubscription + } + } + return _notificationSubscription + } + + func handle(mastodonPushNotification: MastodonPushNotification) { + hasUnreadPushNotification.value = true + + // Subscription maybe failed to cancel when sign-out + // Try cancel again if receive that kind push notification + guard let managedObjectContext = authenticationService?.managedObjectContext else { return } + guard let apiService = apiService else { return } + + managedObjectContext.perform { + let subscriptionRequest = NotificationSubscription.sortedFetchRequest + subscriptionRequest.predicate = NotificationSubscription.predicate(userToken: mastodonPushNotification.accessToken) + let subscriptions = managedObjectContext.safeFetch(subscriptionRequest) + + // note: assert setting remove after cancel subscription + guard let subscription = subscriptions.first else { return } + guard let setting = subscription.setting else { return } + let domain = setting.domain + let userID = setting.userID + + let authenticationRequest = MastodonAuthentication.sortedFetchRequest + authenticationRequest.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) + let authentication = managedObjectContext.safeFetch(authenticationRequest).first + + guard authentication == nil else { + // do nothing if still sign-in + return + } + + // cancel subscription if sign-out + let accessToken = mastodonPushNotification.accessToken + let mastodonAuthenticationBox = AuthenticationService.MastodonAuthenticationBox( + domain: domain, + userID: userID, + appAuthorization: .init(accessToken: accessToken), + userAuthorization: .init(accessToken: accessToken) + ) + apiService + .cancelSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] failed to cancel sign-out user subscription: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] cancel sign-out user subscription", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + } + } + +} + +// MARK: - NotificationViewModel + +extension NotificationService { + final class NotificationViewModel { + + var disposeBag = Set() + + // input + let domain: String + let userID: Mastodon.Entity.Account.ID + + // output + + init(domain: String, userID: Mastodon.Entity.Account.ID) { + self.domain = domain + self.userID = userID + } + } +} + +extension NotificationService.NotificationViewModel { + func createSubscribeQuery( + deviceToken: Data, + queryData: Mastodon.API.Subscriptions.QueryData, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> Mastodon.API.Subscriptions.CreateSubscriptionQuery { + let deviceToken = [UInt8](deviceToken).toHexString() + + let appSecret = AppSecret.default + let endpoint = appSecret.notificationEndpoint + "/" + deviceToken + let p256dh = appSecret.notificationPublicKey.x963Representation + let auth = appSecret.notificationAuth + + let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( + subscription: Mastodon.API.Subscriptions.QuerySubscription( + endpoint: endpoint, + keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( + p256dh: p256dh, + auth: auth + ) + ), + data: queryData + ) + + return query + } + +} diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift new file mode 100644 index 000000000..8683b3972 --- /dev/null +++ b/Mastodon/Service/SettingService.swift @@ -0,0 +1,173 @@ +// +// SettingService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +final class SettingService { + + var disposeBag = Set() + + private var currentSettingUpdateSubscription: AnyCancellable? + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + weak var notificationService: NotificationService? + + // output + let settingFetchedResultController: SettingFetchedResultController + let currentSetting = CurrentValueSubject(nil) + + init( + apiService: APIService, + authenticationService: AuthenticationService, + notificationService: NotificationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + self.notificationService = notificationService + self.settingFetchedResultController = SettingFetchedResultController( + managedObjectContext: authenticationService.managedObjectContext, + additionalPredicate: nil + ) + + // create setting (if non-exist) for authenticated users + authenticationService.mastodonAuthenticationBoxes + .compactMap { [weak self] mastodonAuthenticationBoxes -> AnyPublisher<[AuthenticationService.MastodonAuthenticationBox], Never>? in + guard let self = self else { return nil } + guard let authenticationService = self.authenticationService else { return nil } + guard let activeMastodonAuthenticationBox = mastodonAuthenticationBoxes.first else { return nil } + + let domain = activeMastodonAuthenticationBox.domain + let userID = activeMastodonAuthenticationBox.userID + return authenticationService.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSetting( + into: authenticationService.backgroundManagedObjectContext, + property: Setting.Property( + domain: domain, + userID: userID, + appearanceRaw: SettingsItem.AppearanceMode.automatic.rawValue + ) + ) + } + .map { _ in mastodonAuthenticationBoxes } + .eraseToAnyPublisher() + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + + // bind current setting + Publishers.CombineLatest( + authenticationService.activeMastodonAuthenticationBox, + settingFetchedResultController.settings + ) + .sink { [weak self] activeMastodonAuthenticationBox, settings in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } + let currentSetting = settings.first(where: { setting in + return setting.domain == activeMastodonAuthenticationBox.domain && + setting.userID == activeMastodonAuthenticationBox.userID + }) + self.currentSetting.value = currentSetting + } + .store(in: &disposeBag) + + // observe current setting + currentSetting + .receive(on: DispatchQueue.main) + .sink { [weak self] setting in + guard let self = self else { return } + guard let setting = setting else { + self.currentSettingUpdateSubscription = nil + return + } + + self.currentSettingUpdateSubscription = ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { change in + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + + // observe apparance mode + switch setting.appearance { + case .automatic: UserDefaults.shared.customUserInterfaceStyle = .unspecified + case .light: UserDefaults.shared.customUserInterfaceStyle = .light + case .dark: UserDefaults.shared.customUserInterfaceStyle = .dark + } + }) + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + notificationService.deviceToken, + currentSetting.eraseToAnyPublisher(), + authenticationService.activeMastodonAuthenticationBox + ) + .compactMap { [weak self] deviceToken, setting, activeMastodonAuthenticationBox -> AnyPublisher, Error>? in + guard let self = self else { return nil } + guard let deviceToken = deviceToken else { return nil } + guard let setting = setting else { return nil } + guard let authenticationBox = activeMastodonAuthenticationBox else { return nil } + + guard let subscription = setting.activeSubscription else { return nil } + + guard setting.domain == authenticationBox.domain, + setting.userID == authenticationBox.userID else { return nil } + + let _viewModel = self.notificationService?.dequeueNotificationViewModel( + mastodonAuthenticationBox: authenticationBox + ) + guard let viewModel = _viewModel else { return nil } + let queryData = Mastodon.API.Subscriptions.QueryData( + policy: subscription.policy, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts( + favourite: subscription.alert.favourite, + follow: subscription.alert.follow, + reblog: subscription.alert.reblog, + mention: subscription.alert.mention, + poll: subscription.alert.poll + ) + ) + let query = viewModel.createSubscribeQuery( + deviceToken: deviceToken, + queryData: queryData, + mastodonAuthenticationBox: authenticationBox + ) + + return apiService.createSubscription( + subscriptionObjectID: subscription.objectID, + query: query, + mastodonAuthenticationBox: authenticationBox + ) + } + .debounce(for: .seconds(3), scheduler: DispatchQueue.main) // limit subscribe request emit time interval + .sink(receiveValue: { [weak self] publisher in + guard let self = self else { return } + publisher + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe failure: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + }) + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 903cb7693..93287f6eb 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -28,6 +28,8 @@ class AppContext: ObservableObject { let videoPlaybackService = VideoPlaybackService() let statusPrefetchingService: StatusPrefetchingService let statusPublishService = StatusPublishService() + let notificationService: NotificationService + let settingService: SettingService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -45,11 +47,12 @@ class AppContext: ObservableObject { let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext) apiService = _apiService - authenticationService = AuthenticationService( + let _authenticationService = AuthenticationService( managedObjectContext: _managedObjectContext, backgroundManagedObjectContext: _backgroundManagedObjectContext, apiService: _apiService ) + authenticationService = _authenticationService emojiService = EmojiService( apiService: apiService @@ -57,6 +60,17 @@ class AppContext: ObservableObject { statusPrefetchingService = StatusPrefetchingService( apiService: _apiService ) + let _notificationService = NotificationService( + apiService: _apiService, + authenticationService: _authenticationService + ) + notificationService = _notificationService + + settingService = SettingService( + apiService: _apiService, + authenticationService: _authenticationService, + notificationService: _notificationService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 72ee1334d..6c49638da 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -5,7 +5,10 @@ // Created by MainasuK Cirno on 2021/1/22. // +import os.log import UIKit +import UserNotifications +import AppShared @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -14,10 +17,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + AppSecret.default.register() + // Update app version info. See: `Settings.bundle` UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion") UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle") + UNUserNotificationCenter.current().delegate = self + application.registerForRemoteNotifications() + return true } @@ -38,13 +46,70 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } - extension AppDelegate { func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all } } +extension AppDelegate { + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + appContext.notificationService.deviceToken.value = deviceToken + } +} + +// MARK: - UNUserNotificationCenterDelegate +extension AppDelegate: UNUserNotificationCenterDelegate { + + // notification present in the foreground + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: notification) else { + completionHandler([]) + return + } + + let notificationID = String(mastodonPushNotification.notificationID) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) + completionHandler([.sound]) + } + + // response to user action for notification + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: response.notification) else { + completionHandler() + return + } + + let notificationID = String(mastodonPushNotification.notificationID) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) + appContext.notificationService.requestRevealNotificationPublisher.send(notificationID) + completionHandler() + } + + private static func mastodonPushNotification(from notification: UNNotification) -> MastodonPushNotification? { + guard let plaintext = notification.request.content.userInfo["plaintext"] as? Data, + let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) else { + return nil + } + + return mastodonPushNotification + } + +} extension AppContext { static var shared: AppContext { diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 0f5e2bd59..8dd978a8c 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -6,10 +6,13 @@ // import UIKit +import Combine import CoreDataStack class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var observations = Set() + var window: UIWindow? var coordinator: SceneCoordinator? @@ -28,8 +31,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() - // update `overrideUserInterfaceStyle` with current setting - SettingsViewController.updateOverrideUserInterfaceStyle(window: window) + UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak self] defaults, _ in + guard let self = self else { return } + self.window?.overrideUserInterfaceStyle = defaults.customUserInterfaceStyle + } + .store(in: &observations) } func sceneDidDisconnect(_ scene: UIScene) { @@ -42,6 +48,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_ scene: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + + // reset notification badge + UserDefaults.shared.notificationBadgeCount = 0 + UIApplication.shared.applicationIconBadgeNumber = 0 } func sceneWillResignActive(_ scene: UIScene) { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index b0ab13edb..c6b56c9e9 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -67,7 +67,7 @@ extension Mastodon.API.Notifications { public static func getNotification( session: URLSession, domain: String, - notificationID: String, + notificationID: Mastodon.Entity.Notification.ID, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index df9168499..b66b89aad 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -21,7 +21,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -54,7 +54,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -88,7 +88,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -114,113 +114,149 @@ extension Mastodon.API.Subscriptions { } .eraseToAnyPublisher() } + + /// Remove current subscription + /// + /// Removes the current Web Push API subscription. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/26 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func removeSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.delete( + url: pushEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.EmptySubscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } extension Mastodon.API.Subscriptions { - public struct CreateSubscriptionQuery: Codable, PostQuery { + + public typealias Policy = QueryData.Policy + + public struct QuerySubscription: Codable { let endpoint: String - let p256dh: String - let auth: String - let favourite: Bool? - let follow: Bool? - let reblog: Bool? - let mention: Bool? - let poll: Bool? - - var queryItems: [URLQueryItem]? { - var items = [URLQueryItem]() - - items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint)) - items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh)) - items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth)) - - if let followValue = follow?.queryItemValue { - let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) - items.append(followItem) - } - - if let favouriteValue = favourite?.queryItemValue { - let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) - items.append(favouriteItem) - } - - if let reblogValue = reblog?.queryItemValue { - let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) - items.append(reblogItem) - } - - if let mentionValue = mention?.queryItemValue { - let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) - items.append(mentionItem) - } - return items - } + let keys: Keys public init( endpoint: String, - p256dh: String, - auth: String, - favourite: Bool?, - follow: Bool?, - reblog: Bool?, - mention: Bool?, - poll: Bool? + keys: Keys ) { self.endpoint = endpoint - self.p256dh = p256dh - self.auth = auth - self.favourite = favourite - self.follow = follow - self.reblog = reblog - self.mention = mention - self.poll = poll + self.keys = keys + } + + public struct Keys: Codable { + let p256dh: String + let auth: String + + public init(p256dh: Data, auth: Data) { + self.p256dh = p256dh.base64UrlEncodedString() + self.auth = auth.base64UrlEncodedString() + } + } + } + + public struct QueryData: Codable { + let policy: Policy? + let alerts: Alerts + + public init( + policy: Policy?, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts + ) { + self.policy = policy + self.alerts = alerts + } + + public struct Alerts: Codable { + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + public init(favourite: Bool?, follow: Bool?, reblog: Bool?, mention: Bool?, poll: Bool?) { + self.favourite = favourite + self.follow = follow + self.reblog = reblog + self.mention = mention + self.poll = poll + } + } + + public enum Policy: RawRepresentable, Codable { + case all + case followed + case follower + case none + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "all": self = .all + case "followed": self = .followed + case "follower": self = .follower + case "none": self = .none + + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .all: return "all" + case .followed: return "followed" + case .follower: return "follower" + case .none: return "none" + case ._other(let value): return value + } + } + } + } + + + public struct CreateSubscriptionQuery: Codable, PostQuery { + let subscription: QuerySubscription + let data: QueryData + + public init( + subscription: Mastodon.API.Subscriptions.QuerySubscription, + data: Mastodon.API.Subscriptions.QueryData + ) { + self.subscription = subscription + self.data = data } } public struct UpdateSubscriptionQuery: Codable, PutQuery { - let favourite: Bool? - let follow: Bool? - let reblog: Bool? - let mention: Bool? - let poll: Bool? - var queryItems: [URLQueryItem]? { - var items = [URLQueryItem]() - - if let followValue = follow?.queryItemValue { - let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) - items.append(followItem) - } - - if let favouriteValue = favourite?.queryItemValue { - let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) - items.append(favouriteItem) - } - - if let reblogValue = reblog?.queryItemValue { - let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) - items.append(reblogItem) - } - - if let mentionValue = mention?.queryItemValue { - let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) - items.append(mentionItem) - } - return items + let data: QueryData + + public init(data: Mastodon.API.Subscriptions.QueryData) { + self.data = data } - public init( - favourite: Bool?, - follow: Bool?, - reblog: Bool?, - mention: Bool?, - poll: Bool? - ) { - self.favourite = favourite - self.follow = follow - self.reblog = reblog - self.mention = mention - self.poll = poll - } + var queryItems: [URLQueryItem]? { nil } } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index cfaa1736d..75af54090 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -157,6 +157,14 @@ extension Mastodon.API { ) -> URLRequest { return buildRequest(url: url, method: .PUT, query: query, authorization: authorization) } + + static func delete( + url: URL, + query: DeleteQuery?, + authorization: OAuth.Authorization? + ) -> URLRequest { + return buildRequest(url: url, method: .DELETE, query: query, authorization: authorization) + } private static func buildRequest( url: URL, diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift index 3ae5718e6..e968c32d6 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -1,5 +1,5 @@ // -// File.swift +// Mastodon+Entity+Subscription.swift // // // Created by ihugo on 2021/4/9. @@ -14,7 +14,7 @@ extension Mastodon.Entity { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/26 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/) public struct Subscription: Codable { @@ -33,30 +33,19 @@ extension Mastodon.Entity { public struct Alerts: Codable { public let follow: Bool? + public let followRequest: Bool? public let favourite: Bool? public let reblog: Bool? public let mention: Bool? public let poll: Bool? - public var followNumber: NSNumber? { - guard let value = follow else { return nil } - return NSNumber(booleanLiteral: value) - } - public var favouriteNumber: NSNumber? { - guard let value = favourite else { return nil } - return NSNumber(booleanLiteral: value) - } - public var reblogNumber: NSNumber? { - guard let value = reblog else { return nil } - return NSNumber(booleanLiteral: value) - } - public var mentionNumber: NSNumber? { - guard let value = mention else { return nil } - return NSNumber(booleanLiteral: value) - } - public var pollNumber: NSNumber? { - guard let value = poll else { return nil } - return NSNumber(booleanLiteral: value) + enum CodingKeys: String, CodingKey { + case follow + case followRequest = "follow_request" + case favourite + case reblog + case mention + case poll } } @@ -74,4 +63,8 @@ extension Mastodon.Entity { serverKey = try container.decode(String.self, forKey: .serverKey) } } + + public struct EmptySubscription: Codable { + + } } diff --git a/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift index 43354394d..48e442b9d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift +++ b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift @@ -35,3 +35,12 @@ extension Data { } } + +extension Data { + func base64UrlEncodedString() -> String { + return base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift index 39f6e3ec4..b729129bd 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -58,3 +58,5 @@ protocol PatchQuery: RequestQuery { } // PUT protocol PutQuery: RequestQuery { } +// DELETE +protocol DeleteQuery: RequestQuery { } diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist new file mode 100644 index 000000000..7db9ec9cb --- /dev/null +++ b/NotificationService/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/NotificationService/MastodonNotification.swift b/NotificationService/MastodonNotification.swift new file mode 100644 index 000000000..f3941b12d --- /dev/null +++ b/NotificationService/MastodonNotification.swift @@ -0,0 +1,35 @@ +// +// MastodonNotification.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +struct MastodonPushNotification: Codable { + + private let _accessToken: String + var accessToken: String { + return String.normalize(base64String: _accessToken) + } + + let notificationID: Int + let notificationType: String + + let preferredLocale: String? + let icon: String? + let title: String + let body: String + + enum CodingKeys: String, CodingKey { + case _accessToken = "access_token" + case notificationID = "notification_id" + case notificationType = "notification_type" + case preferredLocale = "preferred_locale" + case icon + case title + case body + } + +} diff --git a/NotificationService/NotificationService+Decrypt.swift b/NotificationService/NotificationService+Decrypt.swift new file mode 100644 index 000000000..858e7c2c5 --- /dev/null +++ b/NotificationService/NotificationService+Decrypt.swift @@ -0,0 +1,73 @@ +// +// NotificationService+Decrypt.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import Foundation +import CryptoKit + +extension NotificationService { + + static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? { + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to craete shared secret", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32) + + let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) + let key = HKDF.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16) + + let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) + let nonce = HKDF.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12) + + let nonceData = nonce.withUnsafeBytes(Array.init) + + guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to create sealedBox", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + var _plaintext: Data? + do { + _plaintext = try AES.GCM.open(sealedBox, using: key) + } catch { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sealedBox open fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + guard let plaintext = _plaintext else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to open sealedBox", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1]) + guard plaintext.count >= 2 + paddingLength else { + print("1") + fatalError() + } + let unpadded = plaintext.suffix(from: paddingLength + 2) + + return Data(unpadded) + } + + static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data { + var info = Data() + + info.append("Content-Encoding: ".data(using: .utf8)!) + info.append(type.data(using: .utf8)!) + info.append(0) + info.append("P-256".data(using: .utf8)!) + info.append(0) + info.append(0) + info.append(65) + info.append(clientPublicKey) + info.append(0) + info.append(65) + info.append(serverPublicKey) + + return info + } +} diff --git a/NotificationService/NotificationService.entitlements b/NotificationService/NotificationService.entitlements new file mode 100644 index 000000000..d334a5e6d --- /dev/null +++ b/NotificationService/NotificationService.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.joinmastodon.mastodon-temp + + + diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift new file mode 100644 index 000000000..08ba12be4 --- /dev/null +++ b/NotificationService/NotificationService.swift @@ -0,0 +1,108 @@ +// +// NotificationService.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-23. +// + +import UserNotifications +import CommonOSLog +import CryptoKit +import AlamofireImage +import Base85 +import AppShared + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let bestAttemptContent = bestAttemptContent { + // Modify the notification content here... + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let privateKey = AppSecret.default.notificationPrivateKey + let auth = AppSecret.default.notificationAuth + + guard let encodedPayload = bestAttemptContent.userInfo["p"] as? String, + let payload = Data(base85Encoded: encodedPayload, options: [], encoding: .z85) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid payload", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String, + let publicKey = NotificationService.publicKey(encodedPublicKey: encodedPublicKey) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid public key", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String, + let salt = Data(base85Encoded: encodedSalt, options: [], encoding: .z85) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid salt", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let plaintextData = NotificationService.decrypt(payload: payload, salt: salt, auth: auth, privateKey: privateKey, publicKey: publicKey), + let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else { + contentHandler(bestAttemptContent) + return + } + + bestAttemptContent.title = notification.title + bestAttemptContent.subtitle = "" + bestAttemptContent.body = notification.body + bestAttemptContent.sound = .default + bestAttemptContent.userInfo["plaintext"] = plaintextData + + UserDefaults.shared.notificationBadgeCount += 1 + bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount) + + if let urlString = notification.icon, let url = URL(string: urlString) { + let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments") + try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) + let filename = url.lastPathComponent + let fileURL = temporaryDirectoryURL.appendingPathComponent(filename) + + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let _ = self else { return } + switch response.result { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + try? image.pngData()?.write(to: fileURL) + if let attachment = try? UNNotificationAttachment(identifier: filename, url: fileURL, options: nil) { + bestAttemptContent.attachments = [attachment] + } + } + contentHandler(bestAttemptContent) + }) + } else { + contentHandler(bestAttemptContent) + } + } + } + + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} + +extension NotificationService { + static func publicKey(encodedPublicKey: String) -> P256.KeyAgreement.PublicKey? { + guard let publicKeyData = Data(base85Encoded: encodedPublicKey, options: [], encoding: .z85) else { return nil } + return try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) + } +} diff --git a/Podfile b/Podfile index d4bec65d4..ea7075ea8 100644 --- a/Podfile +++ b/Podfile @@ -23,4 +23,20 @@ target 'Mastodon' do # Pods for testing end + target 'NotificationService' do + + end + + target 'AppShared' do + + end + end + +plugin 'cocoapods-keys', { + :project => "Mastodon", + :keys => [ + "notification_endpoint", + "notification_endpoint_debug" + ] +} \ No newline at end of file diff --git a/Podfile.lock b/Podfile.lock index 4f553c4e3..e341a2420 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,12 +1,14 @@ PODS: - DateToolsSwift (5.0.0) - Kanna (5.2.4) + - Keys (1.0.1) - SwiftGen (6.4.0) - "UITextField+Shake (1.2.1)" DEPENDENCIES: - DateToolsSwift (~> 5.0.0) - Kanna (~> 5.2.2) + - Keys (from `Pods/CocoaPodsKeys`) - SwiftGen (~> 6.4.0) - "UITextField+Shake (~> 1.2)" @@ -17,12 +19,17 @@ SPEC REPOS: - SwiftGen - "UITextField+Shake" +EXTERNAL SOURCES: + Keys: + :path: Pods/CocoaPodsKeys + SPEC CHECKSUMS: DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6 Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f + Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9 SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a +PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287 COCOAPODS: 1.10.1 diff --git a/README.md b/README.md index 23957fa16..71194684d 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,10 @@ arch -x86_64 pod install - [AlamofireNetworkActivityIndicator](https://github.com/Alamofire/AlamofireNetworkActivityIndicator) - [Alamofire](https://github.com/Alamofire/Alamofire) - [CommonOSLog](https://github.com/mainasuk/CommonOSLog) +- [CryptoSwift](https://github.com/krzyzanowskim/CryptoSwift) - [DateToolSwift](https://github.com/MatthewYork/DateTools) - [Kanna](https://github.com/tid-kijyun/Kanna) +- [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess.git) - [Kingfisher](https://github.com/onevcat/Kingfisher) - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)