diff --git a/Modules/Account/Sources/Account/AccountManager.swift b/Modules/Account/Sources/Account/AccountManager.swift index bbfe49401..ce197ddbd 100644 --- a/Modules/Account/Sources/Account/AccountManager.swift +++ b/Modules/Account/Sources/Account/AccountManager.swift @@ -7,6 +7,7 @@ // import Foundation +import os import RSCore import RSWeb import Articles @@ -90,6 +91,7 @@ public final class AccountManager: UnreadCountProvider { } public let combinedRefreshProgress = CombinedRefreshProgress() + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AccountManager") public init(accountsFolder: String) { self.accountsFolder = accountsFolder @@ -221,6 +223,13 @@ public final class AccountManager: UnreadCountProvider { } } + public func resumeAllIfSuspended() { + if isSuspended { + resumeAll() + logger.info("Account processing resumed.") + } + } + public func resumeAll() { isSuspended = false for account in accounts { diff --git a/Shared/UserInfoKey.swift b/Shared/UserInfoKey.swift index 693a95bbf..cfc9dc9dc 100644 --- a/Shared/UserInfoKey.swift +++ b/Shared/UserInfoKey.swift @@ -15,7 +15,7 @@ struct UserInfoKey { static let articlePath = "articlePath" static let feedIdentifier = "feedIdentifier" static let draggedFeed = "draggedFeed" // DraggedFeed struct - + static let errorHandler = "errorHandler" // ErrorHandlerBlock static let windowState = "windowState" static let windowFullScreenState = "windowFullScreenState" static let containerExpandedWindowState = "containerExpandedWindowState" diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index e47ab9110..2c4aaedfc 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -70,6 +70,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountRefreshDidFinish(_:)), name: .AccountRefreshDidFinish, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(userDidTriggerManualRefresh(_:)), name: .userDidTriggerManualRefresh, object: nil) } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -122,7 +123,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { DispatchQueue.main.async { - self.resumeDatabaseProcessingIfNecessary() + AccountManager.shared.resumeAllIfSuspended() AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) { self.suspendApplication() completionHandler(.newData) @@ -150,22 +151,27 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC AppDefaults.lastRefresh = Date() } + @objc func userDidTriggerManualRefresh(_ note: Notification) { + + guard let errorHandler = note.userInfo?[UserInfoKey.errorHandler] as? ErrorHandlerBlock else { + assertionFailure("Expected errorHandler in .userDidTriggerManualRefresh userInfo") + return + } + + manualRefresh(errorHandler: errorHandler) + } + // MARK: - API - func manualRefresh(errorHandler: @escaping (Error) -> Void) { + func manualRefresh(errorHandler: @escaping ErrorHandlerBlock) { + + assert(Thread.isMainThread) UIApplication.shared.connectedScenes.compactMap( { $0.delegate as? SceneDelegate }).forEach { $0.cleanUp(conditional: true) } AccountManager.shared.refreshAll(errorHandler: errorHandler) } - func resumeDatabaseProcessingIfNecessary() { - if AccountManager.shared.isSuspended { - AccountManager.shared.resumeAll() - logger.info("Application processing resumed.") - } - } - func prepareAccountsForBackground() { extensionFeedAddRequestFile.suspend() syncTimer?.invalidate() @@ -408,7 +414,7 @@ private extension AppDelegate { let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { return } - resumeDatabaseProcessingIfNecessary() + AccountManager.shared.resumeAllIfSuspended() let account = AccountManager.shared.existingAccount(with: accountID) guard account != nil else { os_log(.debug, "No account found from notification.") @@ -435,7 +441,7 @@ private extension AppDelegate { let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { return } - resumeDatabaseProcessingIfNecessary() + AccountManager.shared.resumeAllIfSuspended() let account = AccountManager.shared.existingAccount(with: accountID) guard account != nil else { os_log(.debug, "No account found from notification.") diff --git a/iOS/ErrorHandler.swift b/iOS/ErrorHandler.swift index 35cf9f53a..43932ba30 100644 --- a/iOS/ErrorHandler.swift +++ b/iOS/ErrorHandler.swift @@ -10,11 +10,13 @@ import UIKit import RSCore import os.log +typealias ErrorHandlerBlock = (Error) -> Void + struct ErrorHandler { private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Application") - public static func present(_ viewController: UIViewController) -> (Error) -> Void { + public static func present(_ viewController: UIViewController) -> ErrorHandlerBlock { return { [weak viewController] error in if UIApplication.shared.applicationState == .active { viewController?.presentError(error) @@ -27,5 +29,4 @@ struct ErrorHandler { public static func log(_ error: Error) { os_log(.error, log: self.log, "%@", error.localizedDescription) } - } diff --git a/iOS/MainFeed/MainFeedViewController.swift b/iOS/MainFeed/MainFeedViewController.swift index 9c50a487a..fa119af2b 100644 --- a/iOS/MainFeed/MainFeedViewController.swift +++ b/iOS/MainFeed/MainFeedViewController.swift @@ -471,7 +471,7 @@ final class MainFeedViewController: UITableViewController, UndoableCommandRunner // This is a hack to make sure that an error dialog doesn't interfere with dismissing the refreshControl. // If the error dialog appears too closely to the call to endRefreshing, then the refreshControl never disappears. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - appDelegate.manualRefresh(errorHandler: ErrorHandler.present(self)) + ManualRefreshNotification.post(errorHandler: ErrorHandler.present(self), sender: self) } } diff --git a/iOS/MainTimeline/TimelineViewController.swift b/iOS/MainTimeline/TimelineViewController.swift index 7aef5bdbf..ffbc06842 100644 --- a/iOS/MainTimeline/TimelineViewController.swift +++ b/iOS/MainTimeline/TimelineViewController.swift @@ -184,7 +184,7 @@ final class TimelineViewController: UITableViewController, UndoableCommandRunner // This is a hack to make sure that an error dialog doesn't interfere with dismissing the refreshControl. // If the error dialog appears too closely to the call to endRefreshing, then the refreshControl never disappears. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - appDelegate.manualRefresh(errorHandler: ErrorHandler.present(self)) + ManualRefreshNotification.post(errorHandler: ErrorHandler.present(self), sender: self) } } diff --git a/iOS/ManualRefreshNotification.swift b/iOS/ManualRefreshNotification.swift new file mode 100644 index 000000000..e03b5f90c --- /dev/null +++ b/iOS/ManualRefreshNotification.swift @@ -0,0 +1,25 @@ +// +// ManualRefreshNotification.swift +// NetNewsWire +// +// Created by Brent Simmons on 2/1/25. +// Copyright © 2025 Ranchero Software. All rights reserved. +// + +import Foundation + +extension Notification.Name { + static let userDidTriggerManualRefresh = Notification.Name("userDidTriggerManualRefresh") +} + +// See UserInfoKey.errorHandler for the required ErrorHandler + +struct ManualRefreshNotification { + + static func post(errorHandler: @escaping ErrorHandlerBlock, sender: Any?) { + Task { @MainActor in + let userInfo = [UserInfoKey.errorHandler: errorHandler] + NotificationCenter.default.post(name: .userDidTriggerManualRefresh, object: sender, userInfo: userInfo) + } + } +} diff --git a/iOS/RootSplitViewController.swift b/iOS/RootSplitViewController.swift index 9bba707a9..ffffc3cdd 100644 --- a/iOS/RootSplitViewController.swift +++ b/iOS/RootSplitViewController.swift @@ -116,7 +116,7 @@ final class RootSplitViewController: UISplitViewController { } @objc func refresh(_ sender: Any?) { - appDelegate.manualRefresh(errorHandler: ErrorHandler.present(self)) + ManualRefreshNotification.post(errorHandler: ErrorHandler.present(self), sender: self) } @objc func goToToday(_ sender: Any?) { diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 34b1c37a0..5a68e55a4 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -267,7 +267,7 @@ final class SceneCoordinator: NSObject, UndoableCommandRunner { } var isAnyUnreadAvailable: Bool { - return appDelegate.unreadCount > 0 + return AccountManager.shared.unreadCount > 0 } var timelineUnreadCount: Int = 0 @@ -918,7 +918,7 @@ final class SceneCoordinator: NSObject, UndoableCommandRunner { // This should never happen, but I don't want to risk throwing us // into an infinite loop searching for an unread that isn't there. - if appDelegate.unreadCount < 1 { + if AccountManager.shared.unreadCount < 1 { return } @@ -939,7 +939,7 @@ final class SceneCoordinator: NSObject, UndoableCommandRunner { // This should never happen, but I don't want to risk throwing us // into an infinite loop searching for an unread that isn't there. - if appDelegate.unreadCount < 1 { + if AccountManager.shared.unreadCount < 1 { return } diff --git a/iOS/SceneDelegate.swift b/iOS/SceneDelegate.swift index c18d09533..bc7f123bc 100644 --- a/iOS/SceneDelegate.swift +++ b/iOS/SceneDelegate.swift @@ -65,13 +65,13 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { - appDelegate.resumeDatabaseProcessingIfNecessary() + AccountManager.shared.resumeAllIfSuspended() handleShortcutItem(shortcutItem) completionHandler(true) } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { - appDelegate.resumeDatabaseProcessingIfNecessary() + AccountManager.shared.resumeAllIfSuspended() coordinator.handle(userActivity) } @@ -81,7 +81,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func sceneWillEnterForeground(_ scene: UIScene) { - appDelegate.resumeDatabaseProcessingIfNecessary() + AccountManager.shared.resumeAllIfSuspended() appDelegate.prepareAccountsForForeground() coordinator.resetFocus() } @@ -93,7 +93,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { // API func handle(_ response: UNNotificationResponse) { - appDelegate.resumeDatabaseProcessingIfNecessary() + AccountManager.shared.resumeAllIfSuspended() coordinator.handle(response) }