NetNewsWire/Frameworks/Account/AccountManager.swift

432 lines
11 KiB
Swift
Raw Normal View History

2017-05-27 19:43:27 +02:00
//
// AccountManager.swift
2018-08-29 07:18:24 +02:00
// NetNewsWire
2017-05-27 19:43:27 +02:00
//
// Created by Brent Simmons on 7/18/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import Articles
import ArticlesDatabase
2017-05-27 19:43:27 +02:00
// Main thread only.
2017-09-17 21:34:10 +02:00
public final class AccountManager: UnreadCountProvider {
2017-05-27 19:43:27 +02:00
public static var shared: AccountManager!
public let defaultAccount: Account
private let accountsFolder: String
2017-05-27 19:43:27 +02:00
private var accountsDictionary = [String: Account]()
private let defaultAccountFolderName = "OnMyMac"
private let defaultAccountIdentifier = "OnMyMac"
public var isSuspended = false
public var isUnreadCountsInitialized: Bool {
for account in activeAccounts {
if !account.isUnreadCountsInitialized {
return false
}
}
return true
}
2017-09-17 21:34:10 +02:00
public var unreadCount = 0 {
2017-05-27 19:43:27 +02:00
didSet {
if unreadCount != oldValue {
postUnreadCountDidChangeNotification()
}
2017-05-27 19:43:27 +02:00
}
}
2017-09-24 21:24:44 +02:00
public var accounts: [Account] {
return Array(accountsDictionary.values)
2017-05-27 19:43:27 +02:00
}
2017-09-17 21:54:08 +02:00
public var sortedAccounts: [Account] {
return sortByName(accounts)
}
public var activeAccounts: [Account] {
assert(Thread.isMainThread)
return Array(accountsDictionary.values.filter { $0.isActive })
}
public var sortedActiveAccounts: [Account] {
return sortByName(activeAccounts)
2017-05-27 19:43:27 +02:00
}
public var lastArticleFetchEndTime: Date? {
var lastArticleFetchEndTime: Date? = nil
for account in activeAccounts {
if let accountLastArticleFetchEndTime = account.metadata.lastArticleFetchEndTime {
if lastArticleFetchEndTime == nil || lastArticleFetchEndTime! < accountLastArticleFetchEndTime {
lastArticleFetchEndTime = accountLastArticleFetchEndTime
}
}
}
return lastArticleFetchEndTime
}
2017-05-27 19:43:27 +02:00
public func existingActiveAccount(forDisplayName displayName: String) -> Account? {
return AccountManager.shared.activeAccounts.first(where: { $0.nameForDisplay == displayName })
}
2017-09-17 21:34:10 +02:00
public var refreshInProgress: Bool {
for account in activeAccounts {
if account.refreshInProgress {
return true
2017-05-27 19:43:27 +02:00
}
}
return false
2017-05-27 19:43:27 +02:00
}
public var combinedRefreshProgress: CombinedRefreshProgress {
let downloadProgressArray = activeAccounts.map { $0.refreshProgress }
return CombinedRefreshProgress(downloadProgressArray: downloadProgressArray)
}
public init(accountsFolder: String) {
self.accountsFolder = accountsFolder
2017-05-27 19:43:27 +02:00
// The local "On My Mac" account must always exist, even if it's empty.
let localAccountFolder = (accountsFolder as NSString).appendingPathComponent("OnMyMac")
do {
try FileManager.default.createDirectory(atPath: localAccountFolder, withIntermediateDirectories: true, attributes: nil)
}
catch {
assertionFailure("Could not create folder for OnMyMac account.")
abort()
}
2019-05-01 17:28:13 +02:00
defaultAccount = Account(dataFolder: localAccountFolder, type: .onMyMac, accountID: defaultAccountIdentifier)!
accountsDictionary[defaultAccount.accountID] = defaultAccount
2017-05-27 19:43:27 +02:00
2019-05-01 20:13:53 +02:00
readAccountsFromDisk()
2017-05-27 19:43:27 +02:00
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidInitialize(_:)), name: .UnreadCountDidInitialize, object: nil)
2017-05-27 19:43:27 +02:00
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil)
DispatchQueue.main.async {
self.updateUnreadCount()
}
2017-05-27 19:43:27 +02:00
}
// MARK: - API
2017-05-27 19:43:27 +02:00
2019-05-13 14:17:50 +02:00
public func createAccount(type: AccountType) -> Account {
2020-03-18 21:48:44 +01:00
let accountID = type == .cloudKit ? "iCloud" : UUID().uuidString
2019-05-01 20:13:53 +02:00
let accountFolder = (accountsFolder as NSString).appendingPathComponent("\(type.rawValue)_\(accountID)")
do {
try FileManager.default.createDirectory(atPath: accountFolder, withIntermediateDirectories: true, attributes: nil)
} catch {
assertionFailure("Could not create folder for \(accountID) account.")
abort()
}
let account = Account(dataFolder: accountFolder, type: type, accountID: accountID)!
accountsDictionary[accountID] = account
var userInfo = [String: Any]()
userInfo[Account.UserInfoKey.account] = account
NotificationCenter.default.post(name: .UserDidAddAccount, object: self, userInfo: userInfo)
2019-05-01 19:37:13 +02:00
return account
}
2019-05-02 02:22:07 +02:00
public func deleteAccount(_ account: Account) {
guard !account.refreshInProgress else {
return
}
account.prepareForDeletion()
2019-05-02 02:22:07 +02:00
accountsDictionary.removeValue(forKey: account.accountID)
account.isDeleted = true
2019-05-02 02:22:07 +02:00
do {
try FileManager.default.removeItem(atPath: account.dataFolder)
}
catch {
assertionFailure("Could not create folder for OnMyMac account.")
abort()
}
updateUnreadCount()
var userInfo = [String: Any]()
userInfo[Account.UserInfoKey.account] = account
NotificationCenter.default.post(name: .UserDidDeleteAccount, object: self, userInfo: userInfo)
2019-05-02 02:22:07 +02:00
}
2017-09-18 02:56:04 +02:00
public func existingAccount(with accountID: String) -> Account? {
return accountsDictionary[accountID]
2017-05-27 19:43:27 +02:00
}
public func suspendNetworkAll() {
isSuspended = true
accounts.forEach { $0.suspendNetwork() }
}
public func suspendDatabaseAll() {
accounts.forEach { $0.suspendDatabase() }
}
public func resumeAll() {
isSuspended = false
accounts.forEach { $0.resumeDatabaseAndDelegate() }
accounts.forEach { $0.resume() }
}
public func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: (() -> Void)? = nil) {
let group = DispatchGroup()
activeAccounts.forEach { account in
group.enter()
account.receiveRemoteNotification(userInfo: userInfo) {
group.leave()
}
}
group.notify(queue: DispatchQueue.main) {
completion?()
}
}
public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() -> Void)? = nil) {
let group = DispatchGroup()
activeAccounts.forEach { account in
group.enter()
account.refreshAll() { result in
group.leave()
switch result {
case .success:
break
case .failure(let error):
errorHandler(error)
}
}
}
group.notify(queue: DispatchQueue.main) {
completion?()
}
2017-05-27 19:43:27 +02:00
}
2019-05-20 20:51:08 +02:00
public func syncArticleStatusAll(completion: (() -> Void)? = nil) {
let group = DispatchGroup()
activeAccounts.forEach {
group.enter()
$0.syncArticleStatus() { _ in
2019-05-20 20:51:08 +02:00
group.leave()
}
}
group.notify(queue: DispatchQueue.global(qos: .background)) {
2019-05-20 20:51:08 +02:00
completion?()
}
}
public func saveAll() {
accounts.forEach { $0.save() }
}
2017-10-02 22:15:07 +02:00
public func anyAccountHasAtLeastOneFeed() -> Bool {
for account in activeAccounts {
if account.hasAtLeastOneWebFeed() {
2017-05-27 19:43:27 +02:00
return true
}
}
return false
}
public func anyAccountHasFeedWithURL(_ urlString: String) -> Bool {
for account in activeAccounts {
if let _ = account.existingWebFeed(withURL: urlString) {
2017-05-27 19:43:27 +02:00
return true
}
}
return false
}
// MARK: - Fetching Articles
// These fetch articles from active accounts and return a merged Set<Article>.
public func fetchArticles(_ fetchType: FetchType) throws -> Set<Article> {
precondition(Thread.isMainThread)
var articles = Set<Article>()
for account in activeAccounts {
articles.formUnion(try account.fetchArticles(fetchType))
}
return articles
}
public func fetchArticlesAsync(_ fetchType: FetchType, _ completion: @escaping ArticleSetResultBlock) {
precondition(Thread.isMainThread)
var allFetchedArticles = Set<Article>()
let numberOfAccounts = activeAccounts.count
var accountsReporting = 0
guard numberOfAccounts > 0 else {
completion(.success(allFetchedArticles))
return
}
for account in activeAccounts {
account.fetchArticlesAsync(fetchType) { (articleSetResult) in
accountsReporting += 1
switch articleSetResult {
case .success(let articles):
allFetchedArticles.formUnion(articles)
if accountsReporting == numberOfAccounts {
completion(.success(allFetchedArticles))
}
case .failure(let databaseError):
completion(.failure(databaseError))
return
}
}
}
}
// MARK: - Caches
/// Empty caches that can reasonably be emptied  when the app moves to the background, for instance.
public func emptyCaches() {
for account in accounts {
account.emptyCaches()
}
}
// MARK: - Notifications
2017-05-27 19:43:27 +02:00
@objc func unreadCountDidInitialize(_ notification: Notification) {
guard let _ = notification.object as? Account else {
return
}
if isUnreadCountsInitialized {
postUnreadCountDidInitializeNotification()
}
}
@objc dynamic func unreadCountDidChange(_ notification: Notification) {
2017-05-27 19:43:27 +02:00
guard let _ = notification.object as? Account else {
return
}
updateUnreadCount()
}
@objc func accountStateDidChange(_ notification: Notification) {
updateUnreadCount()
}
}
2017-05-27 19:43:27 +02:00
// MARK: - Private
private extension AccountManager {
func updateUnreadCount() {
unreadCount = calculateUnreadCount(activeAccounts)
2017-05-27 19:43:27 +02:00
}
func loadAccount(_ accountSpecifier: AccountSpecifier) -> Account? {
return Account(dataFolder: accountSpecifier.folderPath, type: accountSpecifier.type, accountID: accountSpecifier.identifier)
}
2017-05-27 19:43:27 +02:00
func loadAccount(_ filename: String) -> Account? {
2017-05-27 19:43:27 +02:00
let folderPath = (accountsFolder as NSString).appendingPathComponent(filename)
if let accountSpecifier = AccountSpecifier(folderPath: folderPath) {
2019-05-01 20:13:53 +02:00
return loadAccount(accountSpecifier)
2017-05-27 19:43:27 +02:00
}
return nil
}
func readAccountsFromDisk() {
2017-05-27 19:43:27 +02:00
var filenames: [String]?
do {
filenames = try FileManager.default.contentsOfDirectory(atPath: accountsFolder)
}
catch {
print("Error reading Accounts folder: \(error)")
return
}
filenames?.forEach { (oneFilename) in
2019-05-01 17:28:13 +02:00
guard oneFilename != defaultAccountFolderName else {
2017-05-27 19:43:27 +02:00
return
}
2019-05-01 20:13:53 +02:00
if let oneAccount = loadAccount(oneFilename) {
accountsDictionary[oneAccount.accountID] = oneAccount
2017-05-27 19:43:27 +02:00
}
}
}
func sortByName(_ accounts: [Account]) -> [Account] {
2017-05-27 19:43:27 +02:00
// LocalAccount is first.
return accounts.sorted { (account1, account2) -> Bool in
if account1 === defaultAccount {
2017-05-27 19:43:27 +02:00
return true
}
if account2 === defaultAccount {
2017-05-27 19:43:27 +02:00
return false
}
return (account1.nameForDisplay as NSString).localizedStandardCompare(account2.nameForDisplay) == .orderedAscending
2017-05-27 19:43:27 +02:00
}
}
}
private struct AccountSpecifier {
2019-05-01 20:13:53 +02:00
let type: AccountType
2017-05-27 19:43:27 +02:00
let identifier: String
let folderPath: String
let folderName: String
let dataFilePath: String
init?(folderPath: String) {
2020-01-12 00:19:32 +01:00
if !FileManager.default.isFolder(atPath: folderPath) {
return nil
}
2019-05-01 20:13:53 +02:00
let name = NSString(string: folderPath).lastPathComponent
if name.hasPrefix(".") {
return nil
}
2019-05-01 20:13:53 +02:00
let nameComponents = name.components(separatedBy: "_")
guard nameComponents.count == 2, let rawType = Int(nameComponents[0]), let accountType = AccountType(rawValue: rawType) else {
2017-05-27 19:43:27 +02:00
return nil
}
self.folderPath = folderPath
self.folderName = name
self.type = accountType
2017-05-27 19:43:27 +02:00
self.identifier = nameComponents[1]
self.dataFilePath = AccountSpecifier.accountFilePathWithFolder(self.folderPath)
2017-05-27 19:43:27 +02:00
}
private static let accountDataFileName = "AccountData.plist"
2017-05-27 19:43:27 +02:00
private static func accountFilePathWithFolder(_ folderPath: String) -> String {
return NSString(string: folderPath).appendingPathComponent(accountDataFileName)
}
}