diff --git a/Account/Package.swift b/Account/Package.swift index 1cc214e81..48ee3e33e 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -27,7 +27,8 @@ let package = Package( .package(path: "../LocalAccount"), .package(path: "../FeedFinder"), .package(path: "../Feedly"), - .package(path: "../CommonErrors") + .package(path: "../CommonErrors"), + .package(path: "../FeedDownloader") ], targets: [ .target( @@ -49,7 +50,8 @@ let package = Package( "LocalAccount", "FeedFinder", "CommonErrors", - "Feedly" + "Feedly", + "FeedDownloader" ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index 2510e6cba..2b2f85190 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -180,6 +180,14 @@ public enum FetchType { return _externalIDToFeedDictionary } + private var _feedURLToFeedDictionary = [String: Feed]() + var feedURLToFeedDictionary: [String: Feed] { + if feedDictionariesNeedUpdate { + rebuildFeedDictionaries() + } + return _feedURLToFeedDictionary + } + var flattenedFeedURLs: Set { return Set(flattenedFeeds().map({ $0.url })) } @@ -1148,9 +1156,11 @@ private extension Account { func rebuildFeedDictionaries() { var idDictionary = [String: Feed]() var externalIDDictionary = [String: Feed]() - + var urlDictionary = [String: Feed]() + for feed in flattenedFeeds() { idDictionary[feed.feedID] = feed + urlDictionary[feed.url] = feed if let externalID = feed.externalID { externalIDDictionary[externalID] = feed } @@ -1158,6 +1168,7 @@ private extension Account { _idToFeedDictionary = idDictionary _externalIDToFeedDictionary = externalIDDictionary + _feedURLToFeedDictionary = urlDictionary feedDictionariesNeedUpdate = false } @@ -1319,6 +1330,10 @@ extension Account { public func existingFeed(withExternalID externalID: String) -> Feed? { return externalIDToFeedDictionary[externalID] } + + public func existingFeed(urlString: String) -> Feed? { + return feedURLToFeedDictionary[urlString] + } } // MARK: - OPMLRepresentable diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index f10fd3127..3b5cbade4 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -48,11 +48,11 @@ enum CloudKitAccountDelegateError: LocalizedError { private let mainThreadOperationQueue = MainThreadOperationQueue() - private lazy var refresher: LocalAccountRefresher = { - let refresher = LocalAccountRefresher() - refresher.delegate = self - return refresher - }() +// private lazy var refresher: LocalAccountRefresher = { +// let refresher = LocalAccountRefresher() +// refresher.delegate = self +// return refresher +// }() weak var account: Account? @@ -459,7 +459,7 @@ enum CloudKitAccountDelegateError: LocalizedError { func suspendNetwork() { - refresher.suspend() +// refresher.suspend() } func suspendDatabase() { @@ -471,7 +471,7 @@ enum CloudKitAccountDelegateError: LocalizedError { func resume() { - refresher.resume() +// refresher.resume() Task { await database.resume() @@ -536,7 +536,7 @@ private extension CloudKitAccountDelegate { func combinedRefresh(_ account: Account, _ feeds: Set) async throws { - await refresher.refreshFeeds(feeds) +// await refresher.refreshFeeds(feeds) } func createRSSFeed(for account: Account, url: URL, editedName: String?, container: Container, validateFeed: Bool) async throws -> Feed { @@ -733,22 +733,22 @@ private extension CloudKitAccountDelegate { } } -extension CloudKitAccountDelegate: LocalAccountRefresherDelegate { - - func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: Feed) { - - refreshProgress.completeTask() - } - - func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges, completion: @escaping () -> Void) { - - Task { @MainActor in - await storeArticleChanges(new: articleChanges.newArticles, - updated: articleChanges.updatedArticles, - deleted: articleChanges.deletedArticles) - } - } -} +//extension CloudKitAccountDelegate: LocalAccountRefresherDelegate { +// +// func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: Feed) { +// +// refreshProgress.completeTask() +// } +// +// func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges, completion: @escaping () -> Void) { +// +// Task { @MainActor in +// await storeArticleChanges(new: articleChanges.newArticles, +// updated: articleChanges.updatedArticles, +// deleted: articleChanges.deletedArticles) +// } +// } +//} extension CloudKitAccountDelegate: CloudKitFeedInfoDelegate { diff --git a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift index 8cd314ba0..93300f99b 100644 --- a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift @@ -18,6 +18,7 @@ import Core import CommonErrors import FeedFinder import LocalAccount +import FeedDownloader public enum LocalAccountDelegateError: String, Error { case invalidParameter = "An invalid parameter was used." @@ -25,16 +26,10 @@ public enum LocalAccountDelegateError: String, Error { final class LocalAccountDelegate: AccountDelegate { - private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "LocalAccount") + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "LocalAccount") weak var account: Account? - private lazy var refresher: LocalAccountRefresher = { - let refresher = LocalAccountRefresher() - refresher.delegate = self - return refresher - }() - let behaviors: AccountBehaviors = [] let isOPMLImportInProgress = false @@ -42,8 +37,17 @@ final class LocalAccountDelegate: AccountDelegate { var credentials: Credentials? var accountMetadata: AccountMetadata? - let refreshProgress = DownloadProgress(numberOfTasks: 0) - + var refreshProgress: DownloadProgress { + feedDownloader.downloadProgress + } + + let feedDownloader: FeedDownloader + + init() { + self.feedDownloader = FeedDownloader() + feedDownloader.delegate = self + } + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any]) async { } @@ -54,12 +58,10 @@ final class LocalAccountDelegate: AccountDelegate { } let feeds = account.flattenedFeeds() - refreshProgress.addToNumberOfTasksAndRemaining(feeds.count) + let feedURLStrings = feeds.map { $0.url } + let feedURLs = Set(feedURLStrings.compactMap { URL(string: $0) }) - await refresher.refreshFeeds(feeds) - - self.refreshProgress.clear() - account.metadata.lastArticleFetchEndTime = Date() + feedDownloader.downloadFeeds(feedURLs) } func syncArticleStatus(for account: Account) async throws { @@ -164,7 +166,9 @@ final class LocalAccountDelegate: AccountDelegate { // MARK: Suspend and Resume (for iOS) func suspendNetwork() { - refresher.suspend() + Task { @MainActor in + await feedDownloader.suspend() + } } func suspendDatabase() { @@ -172,23 +176,10 @@ final class LocalAccountDelegate: AccountDelegate { } func resume() { - refresher.resume() + feedDownloader.resume() } } -extension LocalAccountDelegate: LocalAccountRefresherDelegate { - - - func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: Feed) { - refreshProgress.completeTask() - } - - func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges, completion: @escaping () -> Void) { - completion() - } - -} - private extension LocalAccountDelegate { func createRSSFeed(for account: Account, url: URL, editedName: String?, container: Container) async throws -> Feed { @@ -197,9 +188,7 @@ private extension LocalAccountDelegate { // container before the name has been downloaded. This will put it in the sidebar // with an Untitled name if we don't delay it being added to the sidebar. BatchUpdate.shared.start() - refreshProgress.addToNumberOfTasksAndRemaining(1) defer { - refreshProgress.completeTask() BatchUpdate.shared.end() } @@ -227,3 +216,68 @@ private extension LocalAccountDelegate { return feed } } + +extension LocalAccountDelegate: FeedDownloaderDelegate { + + func feedDownloader(_: FeedDownloader, requestCompletedForFeedURL feedURL: URL, response: URLResponse?, data: Data?, error: Error?) { + + guard let account, let feed = account.existingFeed(urlString: feedURL.absoluteString) else { + return + } + + if let error { + logger.debug("Error downloading \(feed.url) - \(error)") + return + } + guard let response, let data, !data.isEmpty else { + return + } + + parseAndUpdateFeed(feed, response: response, data: data) + } + + func feedDownloader(_: FeedDownloader, requestCanceledForFeedURL feedURL: URL, response: URLResponse?, data: Data?, error: Error?, reason: FeedDownloader.CancellationReason) { + + // nothing to do + } + + func feedDownloaderSessionDidComplete(_: FeedDownloader) { + + account?.metadata.lastArticleFetchEndTime = Date() + } + + func feedDownloader(_: FeedDownloader, conditionalGetInfoFor feedURL: URL) -> HTTPConditionalGetInfo? { + + guard let feed = account?.existingFeed(urlString: feedURL.absoluteString) else { + return nil + } + return feed.conditionalGetInfo + } +} + +private extension LocalAccountDelegate { + + func parseAndUpdateFeed(_ feed: Feed, response: URLResponse, data: Data) { + + Task { @MainActor in + + let dataHash = data.md5String + if dataHash == feed.contentHash { + return + } + + let parserData = ParserData(url: feed.url, data: data) + + guard let parsedFeed = try? await FeedParser.parse(parserData) else { + return + } + + try await self.account?.update(feed: feed, with: parsedFeed) + + if let httpResponse = response as? HTTPURLResponse { + feed.conditionalGetInfo = HTTPConditionalGetInfo(urlResponse: httpResponse) + } + feed.contentHash = dataHash + } + } +}