iOS/Sources/App/AppDelegate.swift

421 lines
14 KiB
Swift

import Alamofire
import CallbackURLKit
import Communicator
import FirebaseCore
import FirebaseMessaging
import Intents
import KeychainAccess
import MBProgressHUD
import ObjectMapper
import PromiseKit
import RealmSwift
import SafariServices
import Shared
import UIKit
import WidgetKit
import XCGLogger
let keychain = AppConstants.Keychain
let prefs = UserDefaults(suiteName: AppConstants.AppGroupID)!
private extension UIApplication {
var typedDelegate: AppDelegate {
// swiftlint:disable:next force_cast
delegate as! AppDelegate
}
}
extension AppEnvironment {
var sceneManager: SceneManager {
UIApplication.shared.typedDelegate.sceneManager
}
var notificationManager: NotificationManager {
UIApplication.shared.typedDelegate.notificationManager
}
}
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
let sceneManager = SceneManager()
private let lifecycleManager = LifecycleManager()
let notificationManager = NotificationManager()
private var zoneManager: ZoneManager?
private var titleSubscription: MenuManagerTitleSubscription? {
didSet {
if oldValue != titleSubscription {
oldValue?.cancel()
}
}
}
private var watchCommunicatorService: WatchCommunicatorService?
func application(
_ application: UIApplication,
willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
MaterialDesignIcons.register()
guard !Current.isRunningTests else {
return true
}
setDefaults()
Current.backgroundTask = ApplicationBackgroundTaskRunner()
Current.isBackgroundRequestsImmediate = { [lifecycleManager] in
if Current.isCatalyst {
return false
} else {
return lifecycleManager.isActive
}
}
Current.isForegroundApp = { [lifecycleManager] in
lifecycleManager.isActive
}
#if targetEnvironment(simulator)
Current.tags = SimulatorTagManager()
#else
Current.tags = iOSTagManager()
#endif
notificationManager.setupNotifications()
setupFirebase()
setupModels()
setupLocalization()
setupMenus()
let launchingForLocation = launchOptions?[.location] != nil
let event = ClientEvent(
text: "Application Starting" + (launchingForLocation ? " due to location change" : ""),
type: .unknown
)
Current.clientEventStore.addEvent(event).cauterize()
zoneManager = ZoneManager()
UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
setupWatchCommunicator()
return true
}
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if NSClassFromString("XCTest") != nil {
return true
}
lifecycleManager.didFinishLaunching()
checkForUpdate()
checkForAlerts()
return true
}
override func buildMenu(with builder: UIMenuBuilder) {
if builder.system == .main {
let manager = MenuManager(builder: builder)
manager.update()
#if targetEnvironment(macCatalyst)
titleSubscription = manager.subscribeStatusItemTitle(
existing: titleSubscription,
update: Current.macBridge.configureStatusItem(title:)
)
#endif
}
}
@objc func openAbout() {
precondition(Current.sceneManager.supportsMultipleScenes)
sceneManager.activateAnyScene(for: .about)
}
@objc func openMenuUrl(_ command: AnyObject) {
guard let command = command as? UICommand, let url = MenuManager.url(from: command) else {
return
}
let delegate: Guarantee<WebViewSceneDelegate> = sceneManager.scene(for: .init(activity: .webView))
delegate.done {
$0.urlHandler?.handle(url: url)
}
}
@objc func openPreferences() {
precondition(Current.sceneManager.supportsMultipleScenes)
sceneManager.activateAnyScene(for: .settings)
}
@objc func openActionsPreferences() {
precondition(Current.sceneManager.supportsMultipleScenes)
let delegate: Guarantee<SettingsSceneDelegate> = sceneManager.scene(for: .init(activity: .settings))
delegate.done { $0.pushActions(animated: true) }
}
@objc func openHelp() {
openURLInBrowser(
URL(string: "https://companion.home-assistant.io")!,
nil
)
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
if #available(iOS 16.0, *), connectingSceneSession.role == UISceneSession.Role.carTemplateApplication {
return SceneActivity.carPlay.configuration
} else {
let activity = options.userActivities
.compactMap { SceneActivity(activityIdentifier: $0.activityType) }
.first ?? .webView
return activity.configuration
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
notificationManager.didFailToRegisterForRemoteNotifications(error: error)
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
notificationManager.didRegisterForRemoteNotifications(deviceToken: deviceToken)
}
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
notificationManager.didReceiveRemoteNotification(userInfo: userInfo, fetchCompletionHandler: completionHandler)
}
func application(
_ application: UIApplication,
performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .full)
Current.Log.verbose("Background fetch activated at \(timestamp)!")
DataWidgetsUpdater.update()
#if !targetEnvironment(macCatalyst)
if UIDevice.current.userInterfaceIdiom == .phone, case .paired = Communicator.shared.currentWatchState {
Current.Log.verbose("Requesting watch sync from background fetch")
Communicator.shared.send(GuaranteedMessage(identifier: GuaranteedMessages.sync.rawValue)) { error in
Current.Log.error("Failed to request watch sync from background fetch: \(error)")
}
}
#endif
Current.backgroundTask(withName: "background-fetch") { remaining in
let updatePromise: Promise<Void>
if Current.settingsStore.isLocationEnabled(for: UIApplication.shared.applicationState),
Current.settingsStore.locationSources.backgroundFetch {
updatePromise = firstly {
Current.location.oneShotLocation(.BackgroundFetch, remaining)
}.then { location in
when(fulfilled: Current.apis.map {
$0.SubmitLocation(updateType: .BackgroundFetch, location: location, zone: nil)
})
}.asVoid()
} else {
updatePromise = when(fulfilled: Current.apis.map {
$0.UpdateSensors(trigger: .BackgroundFetch, location: nil)
})
}
return updatePromise
}.done {
completionHandler(.newData)
}.catch { error in
Current.Log.error("Error when attempting to update data during background fetch: \(error)")
completionHandler(.failed)
}
}
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
if WebhookManager.isManager(forSessionIdentifier: identifier) {
Current.Log.info("starting webhook handler for \(identifier)")
Current.webhooks.handleBackground(for: identifier, completionHandler: completionHandler)
} else {
Current.Log.error("couldn't find appropriate session for for \(identifier)")
completionHandler()
}
}
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? {
IntentHandlerFactory.handler(for: intent)
}
// MARK: - Private helpers
@objc func checkForUpdate(_ sender: AnyObject? = nil) {
guard Current.updater.isSupported else { return }
let dueToUserInteraction = sender != nil
Current.updater.check(dueToUserInteraction: dueToUserInteraction).done { [sceneManager] update in
let alert = UIAlertController(
title: L10n.Updater.UpdateAvailable.title,
message: update.body,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(
title: L10n.Updater.UpdateAvailable.open(update.name),
style: .default,
handler: { _ in
UIApplication.shared.open(update.htmlUrl, options: [:], completionHandler: nil)
}
))
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: nil))
sceneManager.webViewWindowControllerPromise.done {
$0.present(alert, animated: true, completion: nil)
}
}.catch { [sceneManager] error in
Current.Log.error("check error: \(error)")
if dueToUserInteraction {
let alert = UIAlertController(
title: L10n.Updater.NoUpdatesAvailable.title,
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: nil))
sceneManager.webViewWindowControllerPromise.done {
$0.present(alert, animated: true, completion: nil)
}
}
}
}
private func checkForAlerts() {
firstly {
Current.serverAlerter.check(dueToUserInteraction: false)
}.done { [sceneManager] alert in
sceneManager.webViewWindowControllerPromise.done { controller in
controller.show(alert: alert)
}
}.catch { error in
Current.Log.error("check error: \(error)")
}
showNotificationCategoryAlertIfNeeded()
}
private func showNotificationCategoryAlertIfNeeded() {
guard Current.realm().objects(NotificationCategory.self).isEmpty == false else {
return
}
let userDefaults = UserDefaults.standard
let seenKey = "category-deprecation-3-" + Current.clientVersion().description
guard !userDefaults.bool(forKey: seenKey) else {
return
}
when(fulfilled: Current.apis.compactMap { $0.connection?.caches.user.once().promise })
.done { [sceneManager] users in
guard users.contains(where: \.isAdmin) else {
Current.Log.info("not showing because not an admin anywhere")
return
}
let alert = UIAlertController(
title: L10n.Alerts.Deprecations.NotificationCategory.title,
message: L10n.Alerts.Deprecations.NotificationCategory.message("iOS-2022.4"),
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: L10n.Nfc.List.learnMore, style: .default, handler: { _ in
userDefaults.set(true, forKey: seenKey)
openURLInBrowser(
URL(string: "https://companion.home-assistant.io/app/ios/actionable-notifications")!,
nil
)
}))
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: { _ in
userDefaults.set(true, forKey: seenKey)
}))
sceneManager.webViewWindowControllerPromise.done {
$0.present(alert)
}
}.catch { error in
Current.Log.error("couldn't check for if user: \(error)")
}
}
private func setupWatchCommunicator() {
watchCommunicatorService = WatchCommunicatorService()
watchCommunicatorService?.setup()
}
func setupLocalization() {
Current.localized.add(stringProvider: { request in
if prefs.bool(forKey: "showTranslationKeys") {
return request.key
} else {
return nil
}
})
}
private func setupFirebase() {
let optionsFile: String = {
switch Current.appConfiguration {
case .beta: return "GoogleService-Info-Beta"
case .debug, .fastlaneSnapshot: return "GoogleService-Info-Debug"
case .release: return "GoogleService-Info-Release"
}
}()
if let optionsPath = Bundle.main.path(forResource: optionsFile, ofType: "plist"),
let options = FirebaseOptions(contentsOfFile: optionsPath) {
FirebaseApp.configure(options: options)
} else {
fatalError("no firebase config found")
}
notificationManager.setupFirebase()
}
private func setupModels() {
// Force Realm migration to happen now
_ = Realm.live()
Action.setupObserver()
NotificationCategory.setupObserver()
WidgetOpenPageIntent.setupObserver()
}
private func setupMenus() {
NotificationCenter.default.addObserver(
self,
selector: #selector(menuRelatedSettingDidChange(_:)),
name: SettingsStore.menuRelatedSettingDidChange,
object: nil
)
}
@objc private func menuRelatedSettingDidChange(_ note: Notification) {
UIMenuSystem.main.setNeedsRebuild()
}
// swiftlint:disable:next file_length
}