From d2fb8159197335f14339acdbfde9e867bcf8f6da Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 1 Feb 2025 21:38:43 -0800 Subject: [PATCH] Create and use BackgroundTaskManager. --- iOS/AppDelegate.swift | 304 ++++++++++---------------------- iOS/BackgroundTaskManager.swift | 169 ++++++++++++++++++ 2 files changed, 259 insertions(+), 214 deletions(-) create mode 100644 iOS/BackgroundTaskManager.swift diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index 076191a98..3a8e3d92b 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -7,7 +7,6 @@ // import UIKit -import BackgroundTasks import os import RSCore import Account @@ -17,16 +16,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC var window: UIWindow? - private var backgroundTaskDispatchQueue = DispatchQueue.init(label: "BGTaskScheduler") - - private var waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid - private var isWaitingForSyncTasks = false - - private var syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid - private var isSyncArticleStatusRunning = false - private var coordinator: SceneCoordinator? - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Application") private var unreadCount = 0 { @@ -61,13 +51,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC } if isFirstRun && !AccountManager.shared.anyAccountHasAtLeastOneFeed() { - let localAccount = AccountManager.shared.defaultAccount - DefaultFeedsImporter.importDefaultFeeds(account: localAccount) + DefaultFeedsImporter.importDefaultFeeds(account: AccountManager.shared.defaultAccount) } - registerBackgroundTasks() + BackgroundTaskManager.shared.delegate = self + BackgroundTaskManager.shared.registerTasks() + CacheCleaner.purgeIfNecessary() - initializeHomeScreenQuickActions() + addHomeScreenQuickActions() UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .sound, .alert]) { granted, _ in guard granted else { return } @@ -77,7 +68,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC } UNUserNotificationCenter.current().delegate = self - + _ = UserNotificationManager.shared _ = ExtensionContainersFile.shared _ = ExtensionFeedAddRequestFile.shared @@ -85,10 +76,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC _ = ArticleStatusSyncTimer.shared _ = FaviconDownloader.shared _ = FeedIconDownloader.shared - - #if DEBUG + +#if DEBUG ArticleStatusSyncTimer.shared.update() - #endif +#endif // Create window. let window = UIWindow(frame: UIScreen.main.bounds) @@ -119,14 +110,13 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC rootSplitViewController.show(.primary) } - // Update unread count. self.unreadCount = AccountManager.shared.unreadCount } return true } - func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { DispatchQueue.main.async { AccountManager.shared.resumeAllIfSuspended() AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) { @@ -134,60 +124,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC completionHandler(.newData) } } - } - - func applicationWillTerminate(_ application: UIApplication) { - ArticleStatusSyncTimer.shared.stop() } - func applicationDidEnterBackground(_ application: UIApplication) { - IconImageCache.shared.emptyCache() + func applicationWillEnterForeground(_ application: UIApplication) { + prepareAccountsForForeground() + coordinator?.resetFocus() } - // MARK: - Notifications + private func prepareAccountsForForeground() { - @objc func unreadCountDidChange(_ note: Notification) { - assert(Thread.isMainThread) - assert(note.object is AccountManager) - unreadCount = AccountManager.shared.unreadCount - } + AccountManager.shared.resumeAllIfSuspended() - @objc func accountRefreshDidFinish(_ note: Notification) { - 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) - } - - @objc func userDefaultsDidChange(_ note: Notification) { - updateUserInterfaceStyle() - } - - // MARK: - API - - func manualRefresh(errorHandler: @escaping ErrorHandlerBlock) { - assert(Thread.isMainThread) - coordinator?.cleanUp(conditional: true) - AccountManager.shared.refreshAll(errorHandler: errorHandler) - } - - func prepareAccountsForBackground() { - ExtensionFeedAddRequestFile.shared.suspend() - ArticleStatusSyncTimer.shared.invalidate() - scheduleBackgroundFeedRefresh() - syncArticleStatus() - WidgetDataEncoder.shared.encode() - waitForSyncTasksToFinish() - } - - func prepareAccountsForForeground() { ExtensionFeedAddRequestFile.shared.resume() ArticleStatusSyncTimer.shared.update() @@ -202,6 +149,74 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC } } + func applicationDidEnterBackground(_ application: UIApplication) { + IconImageCache.shared.emptyCache() + ArticleStringFormatter.emptyCaches() + prepareAccountsForBackground() + } + + private func prepareAccountsForBackground() { + ExtensionFeedAddRequestFile.shared.suspend() + ArticleStatusSyncTimer.shared.invalidate() + BackgroundTaskManager.shared.scheduleBackgroundFeedRefresh() + BackgroundTaskManager.shared.syncArticleStatus() + WidgetDataEncoder.shared.encode() + BackgroundTaskManager.shared.waitForSyncTasksToFinish() + } + + func applicationWillTerminate(_ application: UIApplication) { + ArticleStatusSyncTimer.shared.stop() + } + + private func suspendApplication() { + guard UIApplication.shared.applicationState == .background else { return } + + AccountManager.shared.suspendNetworkAll() + AccountManager.shared.suspendDatabaseAll() + ArticleThemeDownloader.shared.cleanUp() + + CoalescingQueue.standard.performCallsImmediately() + coordinator?.suspend() + logger.info("Application processing suspended.") + } +} + +// MARK: - Notifications + +extension AppDelegate { + + @objc func unreadCountDidChange(_ note: Notification) { + assert(Thread.isMainThread) + assert(note.object is AccountManager) + unreadCount = AccountManager.shared.unreadCount + } + + @objc func accountRefreshDidFinish(_ note: Notification) { + AppDefaults.lastRefresh = Date() + } + + @objc func userDidTriggerManualRefresh(_ note: Notification) { + + assert(Thread.isMainThread) + + guard let errorHandler = note.userInfo?[UserInfoKey.errorHandler] as? ErrorHandlerBlock else { + assertionFailure("Expected errorHandler in .userDidTriggerManualRefresh userInfo") + return + } + + coordinator?.cleanUp(conditional: true) + AccountManager.shared.refreshAll(errorHandler: errorHandler) + } + + @objc func userDefaultsDidChange(_ note: Notification) { + updateUserInterfaceStyle() + } +} + +// MARK: - UNUserNotificationCenterDelegate + +extension AppDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.list, .banner, .badge, .sound]) } @@ -225,7 +240,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC } } -// MARK: - App Initialization +// MARK: - Home Screen Quick Actions private extension AppDelegate { @@ -235,7 +250,7 @@ private extension AppDelegate { case addFeed = "com.ranchero.NetNewsWire.ShowAdd" } - private func initializeHomeScreenQuickActions() { + private func addHomeScreenQuickActions() { let unreadTitle = NSLocalizedString("First Unread", comment: "First Unread") let unreadIcon = UIApplicationShortcutIcon(systemImageName: "chevron.down.circle") let unreadItem = UIApplicationShortcutItem(type: ShortcutItemType.firstUnread.rawValue, localizedTitle: unreadTitle, localizedSubtitle: nil, icon: unreadIcon, userInfo: nil) @@ -271,151 +286,12 @@ private extension AppDelegate { } } -// MARK: - Go To Background +// MARK: - BackgroundTaskManagerDelegate -private extension AppDelegate { +extension AppDelegate: BackgroundTaskManagerDelegate { - func waitForSyncTasksToFinish() { - guard !isWaitingForSyncTasks && UIApplication.shared.applicationState == .background else { return } - - isWaitingForSyncTasks = true - - self.waitBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { [weak self] in - guard let self = self else { return } - self.completeProcessing(true) - logger.info("Accounts wait for progress terminated for running too long.") - } - - DispatchQueue.main.async { [weak self] in - self?.waitToComplete { [weak self] suspend in - self?.completeProcessing(suspend) - } - } - } - - func waitToComplete(completion: @escaping (Bool) -> Void) { - guard UIApplication.shared.applicationState == .background else { - logger.info("App came back to foreground, no longer waiting.") - completion(false) - return - } - - if AccountManager.shared.refreshInProgress || isSyncArticleStatusRunning || WidgetDataEncoder.shared.isRunning { - logger.info("Waiting for sync to finish…") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.waitToComplete(completion: completion) - } - } else { - logger.info("Refresh progress complete.") - completion(true) - } - } - - func completeProcessing(_ suspend: Bool) { - if suspend { - suspendApplication() - } - UIApplication.shared.endBackgroundTask(self.waitBackgroundUpdateTask) - self.waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid - isWaitingForSyncTasks = false - } - - func syncArticleStatus() { - guard !isSyncArticleStatusRunning else { return } - - isSyncArticleStatusRunning = true - - let completeProcessing = { [unowned self] in - self.isSyncArticleStatusRunning = false - UIApplication.shared.endBackgroundTask(self.syncBackgroundUpdateTask) - self.syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid - } - - self.syncBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { - completeProcessing() - self.logger.info("Accounts sync processing terminated for running too long.") - } - - DispatchQueue.main.async { - AccountManager.shared.syncArticleStatusAll { - completeProcessing() - } - } - } - - func suspendApplication() { - guard UIApplication.shared.applicationState == .background else { return } - - AccountManager.shared.suspendNetworkAll() - AccountManager.shared.suspendDatabaseAll() - ArticleThemeDownloader.shared.cleanUp() - - CoalescingQueue.standard.performCallsImmediately() - coordinator?.suspend() - logger.info("Application processing suspended.") - } -} - -// MARK: - Background Tasks - -private extension AppDelegate { - - static let refreshTaskIdentifier = "com.ranchero.NetNewsWire.FeedRefresh" - - /// Register all background tasks. - func registerBackgroundTasks() { - // Register background feed refresh. - BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.refreshTaskIdentifier, using: nil) { (task) in - self.performBackgroundFeedRefresh(with: task as! BGAppRefreshTask) - } - } - - /// Schedules a background app refresh based on `AppDefaults.refreshInterval`. - func scheduleBackgroundFeedRefresh() { - let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier) - request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) - - // We send this to a dedicated serial queue because as of 11/05/19 on iOS 13.2 the call to the - // task scheduler can hang indefinitely. - backgroundTaskDispatchQueue.async { - do { - try BGTaskScheduler.shared.submit(request) - } catch { - self.logger.error("Could not schedule app refresh: \(error.localizedDescription)") - } - } - } - - /// Performs background feed refresh. - /// - Parameter task: `BGAppRefreshTask` - /// - Warning: As of Xcode 11 beta 2, when triggered from the debugger this doesn't work. - func performBackgroundFeedRefresh(with task: BGAppRefreshTask) { - - scheduleBackgroundFeedRefresh() // schedule next refresh - - logger.info("Woken to perform account refresh.") - - DispatchQueue.main.async { - if AccountManager.shared.isSuspended { - AccountManager.shared.resumeAll() - } - AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) { [unowned self] in - if !AccountManager.shared.isSuspended { - self.suspendApplication() - logger.info("Account refresh operation completed.") - task.setTaskCompleted(success: true) - } - } - } - - // set expiration handler - task.expirationHandler = { [weak task] in - self.logger.info("Accounts refresh processing terminated for running too long.") - DispatchQueue.main.async { - self.suspendApplication() - task?.setTaskCompleted(success: false) - } - } + func backgroundTaskManagerApplicationShouldSuspend(_: BackgroundTaskManager) { + suspendApplication() } } @@ -441,7 +317,7 @@ private extension AppDelegate { return } account!.markArticles(article!, statusKey: .read, flag: true) { _ in } - self.prepareAccountsForBackground() + prepareAccountsForBackground() account!.syncArticleStatus(completion: { [weak self] _ in if !AccountManager.shared.isSuspended { self?.prepareAccountsForBackground() diff --git a/iOS/BackgroundTaskManager.swift b/iOS/BackgroundTaskManager.swift new file mode 100644 index 000000000..b40ff2f49 --- /dev/null +++ b/iOS/BackgroundTaskManager.swift @@ -0,0 +1,169 @@ +// +// BackgroundTaskManager.swift +// NetNewsWire-iOS +// +// Created by Brent Simmons on 2/1/25. +// Copyright © 2025 Ranchero Software. All rights reserved. +// + +import Foundation +import UIKit +import BackgroundTasks +import os +import Account + +protocol BackgroundTaskManagerDelegate: AnyObject { + /// Called when application should suspend networking, database, and other processing. + func backgroundTaskManagerApplicationShouldSuspend(_: BackgroundTaskManager) +} + +/// Registers and runs background tasks using the iOS BackgroundTasks API. +final class BackgroundTaskManager { + + static let shared = BackgroundTaskManager() + + weak var delegate: BackgroundTaskManagerDelegate? + + private var backgroundTaskDispatchQueue = DispatchQueue.init(label: "BGTaskScheduler") + + private var waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid + private var isWaitingForSyncTasks = false + + private var syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid + private var isSyncArticleStatusRunning = false + + static let refreshTaskIdentifier = "com.ranchero.NetNewsWire.FeedRefresh" + + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "BackgroundTasks") + + /// Register background feed refresh. + func registerTasks() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.refreshTaskIdentifier, using: nil) { task in + self.performBackgroundFeedRefresh(with: task as! BGAppRefreshTask) + } + } + + /// Schedules a background app refresh based on `AppDefaults.refreshInterval`. + func scheduleBackgroundFeedRefresh() { + let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + + // We send this to a dedicated serial queue because as of 11/05/19 on iOS 13.2 the call to the + // task scheduler can hang indefinitely. + backgroundTaskDispatchQueue.async { + do { + try BGTaskScheduler.shared.submit(request) + } catch { + Self.logger.error("Could not schedule app refresh: \(error.localizedDescription)") + } + } + } + + func waitForSyncTasksToFinish() { + guard !isWaitingForSyncTasks && UIApplication.shared.applicationState == .background else { return } + + isWaitingForSyncTasks = true + + waitBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { + self.completeProcessing(true) + Self.logger.info("Accounts wait for progress terminated for running too long.") + } + + DispatchQueue.main.async { + self.waitToComplete { suspend in + self.completeProcessing(suspend) + } + } + } + + func syncArticleStatus() { + guard !isSyncArticleStatusRunning else { return } + + isSyncArticleStatusRunning = true + + let completeProcessing = { [unowned self] in + self.isSyncArticleStatusRunning = false + UIApplication.shared.endBackgroundTask(self.syncBackgroundUpdateTask) + self.syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid + } + + self.syncBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { + completeProcessing() + Self.logger.info("Accounts sync processing terminated for running too long.") + } + + DispatchQueue.main.async { + AccountManager.shared.syncArticleStatusAll { + completeProcessing() + } + } + } +} + +private extension BackgroundTaskManager { + + /// Performs background feed refresh. + /// - Parameter task: `BGAppRefreshTask` + /// - Warning: As of Xcode 11 beta 2, when triggered from the debugger this doesn't work. + func performBackgroundFeedRefresh(with task: BGAppRefreshTask) { + + scheduleBackgroundFeedRefresh() // schedule next refresh + + Self.logger.info("Woken to perform account refresh.") + + DispatchQueue.main.async { + if AccountManager.shared.isSuspended { + AccountManager.shared.resumeAll() + } + AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) { + if !AccountManager.shared.isSuspended { + self.suspendApplication() + Self.logger.info("Account refresh operation completed.") + task.setTaskCompleted(success: true) + } + } + } + + // set expiration handler + task.expirationHandler = { [weak task] in + Self.logger.info("Accounts refresh processing terminated for running too long.") + DispatchQueue.main.async { + self.suspendApplication() + task?.setTaskCompleted(success: false) + } + } + } + + func waitToComplete(completion: @escaping (Bool) -> Void) { + guard UIApplication.shared.applicationState == .background else { + Self.logger.info("App came back to foreground, no longer waiting.") + completion(false) + return + } + + if AccountManager.shared.refreshInProgress || isSyncArticleStatusRunning || WidgetDataEncoder.shared.isRunning { + Self.logger.info("Waiting for sync to finish…") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.waitToComplete(completion: completion) + } + } else { + Self.logger.info("Refresh progress complete.") + completion(true) + } + } + + func completeProcessing(_ suspend: Bool) { + if suspend { + suspendApplication() + } + UIApplication.shared.endBackgroundTask(self.waitBackgroundUpdateTask) + waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid + isWaitingForSyncTasks = false + } + + func suspendApplication() { + assert(delegate != nil) + assert(Thread.isMainThread) + delegate?.backgroundTaskManagerApplicationShouldSuspend(self) + } +}