340 lines
11 KiB
Swift
340 lines
11 KiB
Swift
//
|
||
// AppDelegate.swift
|
||
// NetNewsWire
|
||
//
|
||
// Created by Maurice Parker on 4/8/19.
|
||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||
//
|
||
|
||
import UIKit
|
||
import os
|
||
import RSCore
|
||
import Account
|
||
import Articles
|
||
|
||
@UIApplicationMain
|
||
final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||
|
||
var window: UIWindow?
|
||
|
||
private var coordinator: SceneCoordinator?
|
||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Application")
|
||
|
||
private var unreadCount = 0 {
|
||
didSet {
|
||
if unreadCount != oldValue {
|
||
UNUserNotificationCenter.current().setBadgeCount(unreadCount)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Lifecycle
|
||
|
||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||
|
||
AppDefaults.registerDefaults()
|
||
let isFirstRun = AppDefaults.isFirstRun
|
||
if isFirstRun {
|
||
logger.info("Is first run.")
|
||
}
|
||
|
||
_ = AccountManager.shared
|
||
|
||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: AccountManager.shared)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(accountRefreshDidFinish(_:)), name: .AccountRefreshDidFinish, object: nil)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(userDidTriggerManualRefresh(_:)), name: .userDidTriggerManualRefresh, object: nil)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
|
||
|
||
if isFirstRun && !AccountManager.shared.anyAccountHasAtLeastOneFeed() {
|
||
DefaultFeedsImporter.importDefaultFeeds(account: AccountManager.shared.defaultAccount)
|
||
}
|
||
|
||
BackgroundTaskManager.shared.delegate = self
|
||
BackgroundTaskManager.shared.registerTasks()
|
||
|
||
CacheCleaner.purgeIfNecessary()
|
||
addHomeScreenQuickActions()
|
||
|
||
UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .sound, .alert]) { granted, _ in
|
||
guard granted else { return }
|
||
Task { @MainActor in
|
||
UIApplication.shared.registerForRemoteNotifications()
|
||
}
|
||
}
|
||
|
||
UNUserNotificationCenter.current().delegate = self
|
||
|
||
_ = ArticleThemesManager.shared
|
||
_ = UserNotificationManager.shared
|
||
_ = ExtensionContainersFile.shared
|
||
_ = ExtensionFeedAddRequestFile.shared
|
||
_ = WidgetDataEncoder.shared
|
||
_ = ArticleStatusSyncTimer.shared
|
||
_ = FaviconDownloader.shared
|
||
_ = FeedIconDownloader.shared
|
||
|
||
#if DEBUG
|
||
ArticleStatusSyncTimer.shared.update()
|
||
#endif
|
||
|
||
// Create window.
|
||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||
self.window = window
|
||
|
||
// Create UI and add it to window.
|
||
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
||
let rootSplitViewController = storyboard.instantiateInitialViewController() as! RootSplitViewController
|
||
rootSplitViewController.presentsWithGesture = true
|
||
rootSplitViewController.showsSecondaryOnlyButton = true
|
||
rootSplitViewController.preferredDisplayMode = .oneBesideSecondary
|
||
|
||
coordinator = SceneCoordinator(rootSplitViewController: rootSplitViewController)
|
||
rootSplitViewController.coordinator = coordinator
|
||
rootSplitViewController.delegate = coordinator
|
||
|
||
window.rootViewController = rootSplitViewController
|
||
|
||
window.tintColor = AppColor.accent
|
||
updateUserInterfaceStyle()
|
||
UINavigationBar.appearance().scrollEdgeAppearance = UINavigationBarAppearance()
|
||
|
||
window.makeKeyAndVisible()
|
||
|
||
Task { @MainActor in
|
||
// Ensure Feeds view shows on first run on iPad — otherwise the UI is empty.
|
||
if UIDevice.current.userInterfaceIdiom == .pad && AppDefaults.isFirstRun {
|
||
rootSplitViewController.show(.primary)
|
||
}
|
||
|
||
self.unreadCount = AccountManager.shared.unreadCount
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||
DispatchQueue.main.async {
|
||
AccountManager.shared.resumeAllIfSuspended()
|
||
AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) {
|
||
self.suspendApplication()
|
||
completionHandler(.newData)
|
||
}
|
||
}
|
||
}
|
||
|
||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||
prepareAccountsForForeground()
|
||
coordinator?.resetFocus()
|
||
}
|
||
|
||
private func prepareAccountsForForeground() {
|
||
|
||
AccountManager.shared.resumeAllIfSuspended()
|
||
|
||
ExtensionFeedAddRequestFile.shared.resume()
|
||
ArticleStatusSyncTimer.shared.update()
|
||
|
||
if let lastRefresh = AppDefaults.lastRefresh {
|
||
if Date() > lastRefresh.addingTimeInterval(15 * 60) {
|
||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log)
|
||
} else {
|
||
AccountManager.shared.syncArticleStatusAll()
|
||
}
|
||
} else {
|
||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log)
|
||
}
|
||
}
|
||
|
||
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])
|
||
}
|
||
|
||
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:
|
||
handle(response)
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
self.coordinator?.dismissIfLaunchingFromExternalAction()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Home Screen Quick Actions
|
||
|
||
private extension AppDelegate {
|
||
|
||
enum ShortcutItemType: String {
|
||
case firstUnread = "com.ranchero.NetNewsWire.FirstUnread"
|
||
case showSearch = "com.ranchero.NetNewsWire.ShowSearch"
|
||
case addFeed = "com.ranchero.NetNewsWire.ShowAdd"
|
||
}
|
||
|
||
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)
|
||
|
||
let searchTitle = NSLocalizedString("Search", comment: "Search")
|
||
let searchIcon = UIApplicationShortcutIcon(systemImageName: "magnifyingglass")
|
||
let searchItem = UIApplicationShortcutItem(type: ShortcutItemType.showSearch.rawValue, 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: ShortcutItemType.addFeed.rawValue, localizedTitle: addTitle, localizedSubtitle: nil, icon: addIcon, userInfo: nil)
|
||
|
||
UIApplication.shared.shortcutItems = [addItem, searchItem, unreadItem]
|
||
}
|
||
}
|
||
|
||
// MARK: - Private
|
||
|
||
private extension AppDelegate {
|
||
|
||
func updateUserInterfaceStyle() {
|
||
|
||
assert(Thread.isMainThread)
|
||
guard let window else {
|
||
// Could be nil legitimately — this can get called before window is set up.
|
||
return
|
||
}
|
||
|
||
let updatedStyle = AppDefaults.userInterfaceColorPalette.uiUserInterfaceStyle
|
||
if window.overrideUserInterfaceStyle != updatedStyle {
|
||
window.overrideUserInterfaceStyle = updatedStyle
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - BackgroundTaskManagerDelegate
|
||
|
||
extension AppDelegate: BackgroundTaskManagerDelegate {
|
||
|
||
func backgroundTaskManagerApplicationShouldSuspend(_: BackgroundTaskManager) {
|
||
suspendApplication()
|
||
}
|
||
}
|
||
|
||
// MARK: - Handle Notification Actions
|
||
|
||
private extension AppDelegate {
|
||
|
||
func handleMarkAsRead(userInfo: [AnyHashable: Any]) {
|
||
handleMarked(userInfo: userInfo, statusKey: .read)
|
||
}
|
||
|
||
func handleMarkAsStarred(userInfo: [AnyHashable: Any]) {
|
||
handleMarked(userInfo: userInfo, statusKey: .starred)
|
||
}
|
||
|
||
func handleMarked(userInfo: [AnyHashable: Any], statusKey: ArticleStatus.Key) {
|
||
|
||
guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable: Any],
|
||
let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String,
|
||
let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else {
|
||
return
|
||
}
|
||
|
||
AccountManager.shared.resumeAllIfSuspended()
|
||
|
||
guard let account = AccountManager.shared.existingAccount(with: accountID) else {
|
||
logger.debug("No account found from notification with accountID \(accountID).")
|
||
return
|
||
}
|
||
guard let article = try? account.fetchArticles(.articleIDs([articleID])) else {
|
||
logger.debug("No articles found from search using \(articleID)")
|
||
return
|
||
}
|
||
|
||
account.markArticles(article, statusKey: statusKey, flag: true) { _ in }
|
||
prepareAccountsForBackground()
|
||
account.syncArticleStatus { _ in
|
||
if !AccountManager.shared.isSuspended {
|
||
self.prepareAccountsForBackground()
|
||
self.suspendApplication()
|
||
}
|
||
}
|
||
}
|
||
|
||
func handle(_ response: UNNotificationResponse) {
|
||
AccountManager.shared.resumeAllIfSuspended()
|
||
coordinator?.handle(response)
|
||
}
|
||
}
|