// // AppDelegate.swift // NetNewsWire // // Created by Maurice Parker on 4/8/19. // Copyright © 2019 Ranchero Software. All rights reserved. // import UIKit import Web import Account import BackgroundTasks import os.log import Secrets import WidgetKit import Core var appDelegate: AppDelegate! @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, UnreadCountProvider { private var bgTaskDispatchQueue = DispatchQueue.init(label: "BGTaskScheduler") private var waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid private var syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid var syncTimer: ArticleStatusSyncTimer? var shuttingDown = false { didSet { if shuttingDown { syncTimer?.shuttingDown = shuttingDown syncTimer?.invalidate() } } } var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Application") var userNotificationManager: UserNotificationManager! var faviconDownloader: FaviconDownloader! var imageDownloader: ImageDownloader! var authorAvatarDownloader: AuthorAvatarDownloader! var feedIconDownloader: FeedIconDownloader! var extensionContainersFile: ExtensionContainersFile! var extensionFeedAddRequestFile: ExtensionFeedAddRequestFile! var unreadCount = 0 { didSet { if unreadCount != oldValue { postUnreadCountDidChangeNotification() UNUserNotificationCenter.current().setBadgeCount(unreadCount) } } } var isSyncArticleStatusRunning = false var isWaitingForSyncTasks = false let accountManager: AccountManager private var secretsProvider = Secrets() override init() { let documentFolder = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let documentAccountsFolder = documentFolder.appendingPathComponent("Accounts").absoluteString let documentAccountsFolderPath = String(documentAccountsFolder.suffix(from: documentAccountsFolder.index(documentAccountsFolder.startIndex, offsetBy: 7))) self.accountManager = AccountManager(accountsFolder: documentAccountsFolderPath, secretsProvider: secretsProvider) AccountManager.shared = accountManager super.init() appDelegate = self let documentThemesFolder = documentFolder.appendingPathComponent("Themes").absoluteString let documentThemesFolderPath = String(documentThemesFolder.suffix(from: documentAccountsFolder.index(documentThemesFolder.startIndex, offsetBy: 7))) ArticleThemesManager.shared = ArticleThemesManager(folderPath: documentThemesFolderPath) NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountRefreshDidFinish(_:)), name: .AccountRefreshDidFinish, object: nil) } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { AppDefaults.registerDefaults() let isFirstRun = AppDefaults.shared.isFirstRun if isFirstRun { os_log("Is first run.", log: log, type: .info) } if isFirstRun && !accountManager.anyAccountHasAtLeastOneFeed() { let localAccount = accountManager.defaultAccount DefaultFeedsImporter.importDefaultFeeds(account: localAccount) } registerBackgroundTasks() CacheCleaner.purgeIfNecessary() initializeDownloaders() initializeHomeScreenQuickActions() Task { @MainActor in self.unreadCount = accountManager.unreadCount } UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in if granted { Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() } } } UNUserNotificationCenter.current().delegate = self userNotificationManager = UserNotificationManager() extensionContainersFile = ExtensionContainersFile() extensionFeedAddRequestFile = ExtensionFeedAddRequestFile() syncTimer = ArticleStatusSyncTimer() #if DEBUG syncTimer!.update() #endif return true } func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult { resumeDatabaseProcessingIfNecessary() await accountManager.receiveRemoteNotification(userInfo: userInfo) suspendApplication() return .newData } func applicationWillTerminate(_ application: UIApplication) { shuttingDown = true } func applicationDidEnterBackground(_ application: UIApplication) { IconImageCache.shared.emptyCache() } // MARK: Notifications @objc func unreadCountDidChange(_ note: Notification) { if note.object is AccountManager { unreadCount = accountManager.unreadCount } } @objc func accountRefreshDidFinish(_ note: Notification) { AppDefaults.shared.lastRefresh = Date() } // MARK: - API func manualRefresh(errorHandler: @escaping (Error) -> ()) { UIApplication.shared.connectedScenes.compactMap( { $0.delegate as? SceneDelegate } ).forEach { $0.cleanUp(conditional: true) } Task { @MainActor in await self.accountManager.refreshAll(errorHandler: errorHandler) } } func resumeDatabaseProcessingIfNecessary() { if accountManager.isSuspended { accountManager.resumeAll() os_log("Application processing resumed.", log: self.log, type: .info) } } func prepareAccountsForBackground() { extensionFeedAddRequestFile.suspend() syncTimer?.invalidate() scheduleBackgroundFeedRefresh() syncArticleStatus() waitForSyncTasksToFinish() } func prepareAccountsForForeground() { extensionFeedAddRequestFile.resume() syncTimer?.update() Task { @MainActor in if let lastRefresh = AppDefaults.shared.lastRefresh { if Date() > lastRefresh.addingTimeInterval(15 * 60) { await accountManager.refreshAll(errorHandler: ErrorHandler.log) } else { await accountManager.syncArticleStatusAll() } } else { await accountManager.refreshAll(errorHandler: ErrorHandler.log) } } } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.list, .banner, .badge, .sound]) } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { defer { completionHandler() } let userInfo = response.notification.request.content.userInfo switch response.actionIdentifier { case "MARK_AS_READ": handleMarkAsRead(userInfo: userInfo) case "MARK_AS_STARRED": handleMarkAsStarred(userInfo: userInfo) default: if let sceneDelegate = response.targetScene?.delegate as? SceneDelegate { sceneDelegate.handle(response) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { sceneDelegate.coordinator.dismissIfLaunchingFromExternalAction() }) } } } } // MARK: App Initialization private extension AppDelegate { private func initializeDownloaders() { let tempDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! let faviconsFolderURL = tempDir.appendingPathComponent("Favicons") let imagesFolderURL = tempDir.appendingPathComponent("Images") try! FileManager.default.createDirectory(at: faviconsFolderURL, withIntermediateDirectories: true, attributes: nil) let faviconsFolder = faviconsFolderURL.absoluteString let faviconsFolderPath = faviconsFolder.suffix(from: faviconsFolder.index(faviconsFolder.startIndex, offsetBy: 7)) faviconDownloader = FaviconDownloader(folder: String(faviconsFolderPath)) let imagesFolder = imagesFolderURL.absoluteString let imagesFolderPath = imagesFolder.suffix(from: imagesFolder.index(imagesFolder.startIndex, offsetBy: 7)) try! FileManager.default.createDirectory(at: imagesFolderURL, withIntermediateDirectories: true, attributes: nil) imageDownloader = ImageDownloader(folder: String(imagesFolderPath)) authorAvatarDownloader = AuthorAvatarDownloader(imageDownloader: imageDownloader) let tempFolder = tempDir.absoluteString let tempFolderPath = tempFolder.suffix(from: tempFolder.index(tempFolder.startIndex, offsetBy: 7)) feedIconDownloader = FeedIconDownloader(imageDownloader: imageDownloader, folder: String(tempFolderPath)) } private func initializeHomeScreenQuickActions() { let unreadTitle = NSLocalizedString("First Unread", comment: "First Unread") let unreadIcon = UIApplicationShortcutIcon(systemImageName: "chevron.down.circle") let unreadItem = UIApplicationShortcutItem(type: "com.ranchero.NetNewsWire.FirstUnread", localizedTitle: unreadTitle, localizedSubtitle: nil, icon: unreadIcon, userInfo: nil) let searchTitle = NSLocalizedString("Search", comment: "Search") let searchIcon = UIApplicationShortcutIcon(systemImageName: "magnifyingglass") let searchItem = UIApplicationShortcutItem(type: "com.ranchero.NetNewsWire.ShowSearch", localizedTitle: searchTitle, localizedSubtitle: nil, icon: searchIcon, userInfo: nil) let addTitle = NSLocalizedString("Add Feed", comment: "Add Feed") let addIcon = UIApplicationShortcutIcon(systemImageName: "plus") let addItem = UIApplicationShortcutItem(type: "com.ranchero.NetNewsWire.ShowAdd", localizedTitle: addTitle, localizedSubtitle: nil, icon: addIcon, userInfo: nil) UIApplication.shared.shortcutItems = [addItem, searchItem, unreadItem] } } // MARK: Go To Background private extension AppDelegate { 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) os_log("Accounts wait for progress terminated for running too long.", log: self.log, type: .info) } 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 { os_log("App came back to foreground, no longer waiting.", log: self.log, type: .info) completion(false) return } if accountManager.refreshInProgress || isSyncArticleStatusRunning { os_log("Waiting for sync to finish...", log: self.log, type: .info) DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.waitToComplete(completion: completion) } } else { os_log("Refresh progress complete.", log: self.log, type: .info) 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() os_log("Accounts sync processing terminated for running too long.", log: self.log, type: .info) } Task { @MainActor in await self.accountManager.syncArticleStatusAll() completeProcessing() } } func suspendApplication() { guard UIApplication.shared.applicationState == .background else { return } accountManager.suspendNetworkAll() accountManager.suspendDatabaseAll() ArticleThemeDownloader.cleanUp() CoalescingQueue.standard.performCallsImmediately() for scene in UIApplication.shared.connectedScenes { if let sceneDelegate = scene.delegate as? SceneDelegate { sceneDelegate.suspend() } } os_log("Application processing suspended.", log: self.log, type: .info) } } // MARK: Background Tasks private extension AppDelegate { /// Register all background tasks. func registerBackgroundTasks() { // Register background feed refresh. BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.ranchero.NetNewsWire.FeedRefresh", 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: "com.ranchero.NetNewsWire.FeedRefresh") 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. bgTaskDispatchQueue.async { do { try BGTaskScheduler.shared.submit(request) } catch { os_log(.error, log: self.log, "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 os_log("Woken to perform account refresh.", log: self.log, type: .info) Task { @MainActor in if self.accountManager.isSuspended { self.accountManager.resumeAll() } await self.accountManager.refreshAll(errorHandler: ErrorHandler.log) if !self.accountManager.isSuspended { try? WidgetDataEncoder.shared.encodeWidgetData() self.suspendApplication() os_log("Account refresh operation completed.", log: self.log, type: .info) task.setTaskCompleted(success: true) } } // set expiration handler task.expirationHandler = { [weak task] in DispatchQueue.main.sync { self.suspendApplication() } os_log("Accounts refresh processing terminated for running too long.", log: self.log, type: .info) task?.setTaskCompleted(success: false) } } } // Handle Notification Actions private extension AppDelegate { @MainActor func handleMarkAsRead(userInfo: [AnyHashable: Any]) { guard let articlePathInfo = ArticlePathInfo(userInfo: userInfo) else { return } resumeDatabaseProcessingIfNecessary() guard let accountID = articlePathInfo.accountID, let account = accountManager.existingAccount(with: accountID) else { os_log(.debug, "No account found from notification.") return } guard let articleID = articlePathInfo.articleID else { os_log(.debug, "No articleID found from notification.") return } Task { @MainActor in guard let articles = try? await account.articles(for: .articleIDs([articleID])) else { os_log(.debug, "No article found from search using %@", articleID) return } try? await account.markArticles(articles, statusKey: .read, flag: true) self.prepareAccountsForBackground() try? await account.syncArticleStatus() if !self.accountManager.isSuspended { try? WidgetDataEncoder.shared.encodeWidgetData() self.prepareAccountsForBackground() self.suspendApplication() } } } @MainActor func handleMarkAsStarred(userInfo: [AnyHashable: Any]) { guard let articlePathInfo = ArticlePathInfo(userInfo: userInfo) else { return } resumeDatabaseProcessingIfNecessary() guard let accountID = articlePathInfo.accountID, let account = accountManager.existingAccount(with: accountID) else { os_log(.debug, "No account found from notification.") return } guard let articleID = articlePathInfo.articleID else { os_log(.debug, "No articleID found from notification.") return } Task { @MainActor in guard let articles = try? await account.articles(for: .articleIDs([articleID])) else { os_log(.debug, "No article found from search using %@", articleID) return } try? await account.markArticles(articles, statusKey: .starred, flag: true) try? await account.syncArticleStatus() if !self.accountManager.isSuspended { try? WidgetDataEncoder.shared.encodeWidgetData() self.prepareAccountsForBackground() self.suspendApplication() } } } }